Merge branch 'master' into ja-turn-off-registration

This commit is contained in:
James Allen 2017-11-22 11:45:28 +00:00 committed by GitHub
commit 310aa1d49d
18 changed files with 210 additions and 73 deletions

View file

@ -24,6 +24,7 @@ AnalyticsManager = require "../Analytics/AnalyticsManager"
Sources = require "../Authorization/Sources"
TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
Modules = require '../../infrastructure/Modules'
crypto = require 'crypto'
module.exports = ProjectController =
@ -148,6 +149,11 @@ module.exports = ProjectController =
NotificationsHandler.getUserNotifications user_id, cb
projects: (cb)->
ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref tokens', cb
v1Projects: (cb) ->
Modules.hooks.fire "findAllV1Projects", user_id, (error, projects = []) ->
if error? and error.message == 'No V1 connection'
return cb(null, projects: [], tags: [], noConnection: true)
return cb(error, projects[0]) # hooks.fire returns an array of results, only need first
hasSubscription: (cb)->
LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
user: (cb) ->
@ -157,11 +163,12 @@ module.exports = ProjectController =
logger.err err:err, "error getting data for project list page"
return next(err)
logger.log results:results, user_id:user_id, "rendering project list"
tags = results.tags[0]
v1Tags = results.v1Projects?.tags or []
tags = results.tags[0].concat(v1Tags)
notifications = require("underscore").map results.notifications, (notification)->
notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
return notification
projects = ProjectController._buildProjectList results.projects
projects = ProjectController._buildProjectList results.projects, results.v1Projects?.projects
user = results.user
ProjectController._injectProjectOwners projects, (error, projects) ->
return next(error) if error?
@ -173,6 +180,8 @@ module.exports = ProjectController =
notifications: notifications or []
user: user
hasSubscription: results.hasSubscription[0]
isShowingV1Projects: results.v1Projects?
noV1Connection: results.v1Projects?.noConnection
}
if Settings?.algolia?.app_id? and Settings?.algolia?.read_only_api_key?
@ -280,7 +289,7 @@ module.exports = ProjectController =
# Extract data from user's ObjectId
timestamp = parseInt(user_id.toString().substring(0, 8), 16)
rolloutPercentage = 5 # Percentage of users to roll out to
rolloutPercentage = 10 # Percentage of users to roll out to
if !ProjectController._isInPercentageRollout('autocompile', user_id, rolloutPercentage)
# Don't show if user is not part of roll out
return cb(null, { enabled: false, showOnboarding: false })
@ -333,7 +342,7 @@ module.exports = ProjectController =
enableTokenAccessUI = ProjectController._isInPercentageRollout(
'linksharing',
project.owner_ref,
40
100
)
showLinkSharingOnboarding = enableTokenAccessUI && results.couldShowLinkSharingOnboarding
AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, token, (error, privilegeLevel)->
@ -390,7 +399,7 @@ module.exports = ProjectController =
showLinkSharingOnboarding: showLinkSharingOnboarding
timer.done()
_buildProjectList: (allProjects)->
_buildProjectList: (allProjects, v1Projects = [])->
{owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly} = allProjects
projects = []
for project in owned
@ -400,6 +409,8 @@ module.exports = ProjectController =
projects.push ProjectController._buildProjectViewModel(project, "readWrite", Sources.INVITE)
for project in readOnly
projects.push ProjectController._buildProjectViewModel(project, "readOnly", Sources.INVITE)
for project in v1Projects
projects.push ProjectController._buildV1ProjectViewModel(project)
# Token-access
# Only add these projects if they're not already present, this gives us cascading access
# from 'owner' => 'token-read-only'
@ -424,9 +435,20 @@ module.exports = ProjectController =
archived: !!project.archived
owner_ref: project.owner_ref
tokens: project.tokens
isV1Project: false
}
return model
_buildV1ProjectViewModel: (project) ->
{
id: project.id
name: project.title
lastUpdated: new Date(project.updated_at * 1000) # Convert from epoch
accessLevel: "readOnly",
archived: project.removed || project.archived
isV1Project: true
}
_injectProjectOwners: (projects, callback = (error, projects) ->) ->
users = {}
for project in projects

View file

@ -51,7 +51,6 @@ module.exports = UserController =
updateUserSettings : (req, res)->
user_id = AuthenticationController.getLoggedInUserId(req)
usingExternalAuth = settings.ldap? or settings.saml?
logger.log user_id: user_id, "updating account settings"
User.findById user_id, (err, user)->
if err? or !user?
@ -84,7 +83,7 @@ module.exports = UserController =
user.ace.syntaxValidation = req.body.syntaxValidation
user.save (err)->
newEmail = req.body.email?.trim().toLowerCase()
if !newEmail? or newEmail == user.email or usingExternalAuth
if !newEmail? or newEmail == user.email or req.externalAuthenticationSystemUsed()
# end here, don't update email
AuthenticationController.setInSessionUser(req, {first_name: user.first_name, last_name: user.last_name})
return res.sendStatus 200

View file

@ -0,0 +1,40 @@
.col-xs-6
input.select-item(
select-individual,
type="checkbox",
ng-model="project.selected"
stop-propagation="click"
aria-label=translate('select_project') + " '{{ project.name }}'"
)
span
a.projectName(
ng-href="{{projectLink(project)}}"
stop-propagation="click"
) {{project.name}}
span(
ng-controller="TagListController"
)
.tag-label(
ng-repeat='tag in project.tags'
stop-propagation="click"
)
a.label.label-default.tag-label-name(
href,
ng-click="selectTag(tag)"
) {{tag.name}}
a.label.label-default.tag-label-remove(
href
ng-click="removeProjectFromTag(project, tag)"
) ×
.col-xs-2
span.owner {{ownerName()}}
span(ng-if="isLinkSharingProject(project)")
|  
i.fa.fa-link.small(
tooltip=translate("link_sharing")
tooltip-placement="right"
tooltip-append-to-body="true"
)
.col-xs-4
span.last-modified {{project.lastUpdated | formatDate}}

View file

@ -114,6 +114,10 @@
) #{translate("delete_forever")}
.row.row-spaced
if noV1Connection
.col-xs-12
.alert.alert-warning No V1 Connection
.col-xs-12
.card.card-thin.project-list-card
ul.list-unstyled.project-list.structured-list(
@ -142,47 +146,15 @@
ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
ng-controller="ProjectListItemController"
)
.row(select-row)
.col-xs-6
input.select-item(
select-individual,
type="checkbox",
ng-model="project.selected"
stop-propagation="click"
aria-label=translate('select_project') + " '{{ project.name }}'"
)
span
a.projectName(
ng-href="{{projectLink(project)}}"
stop-propagation="click"
) {{project.name}}
span(
ng-controller="TagListController"
)
.tag-label(
ng-repeat='tag in project.tags'
stop-propagation="click"
)
a.label.label-default.tag-label-name(
href,
ng-click="selectTag(tag)"
) {{tag.name}}
a.label.label-default.tag-label-remove(
href
ng-click="removeProjectFromTag(project, tag)"
) ×
.col-xs-2
span.owner {{ownerName()}}
span(ng-if="isLinkSharingProject(project)")
|  
i.fa.fa-link.small(
tooltip=translate("link_sharing")
tooltip-placement="right"
tooltip-append-to-body="true"
)
.col-xs-4
span.last-modified {{project.lastUpdated | formatDate}}
.row(
ng-if="!project.isV1Project"
select-row
)
include ./item
.row(
ng-if="project.isV1Project"
)
include ./v1-item
li(
ng-if="visibleProjects.length == 0",
ng-cloak

View file

@ -45,6 +45,9 @@
a(href) #{translate("shared_with_you")}
li(ng-class="{active: (filter == 'archived')}", ng-click="filterProjects('archived')")
a(href) #{translate("deleted_projects")}
if isShowingV1Projects
li(ng-class="{active: (filter == 'v1')}", ng-click="filterProjects('v1')")
a(href) #{translate("v1_projects")}
li.separator
h2 #{translate("folders")}
li.tag(
@ -62,6 +65,11 @@
)
span.name {{tag.name}}
span.subdued ({{tag.project_ids.length}})
span.v1-badge(
ng-if="tag.isV1",
ng-cloak,
aria-label=translate("v1_badge")
)
span.dropdown.tag-menu(dropdown)
a.dropdown-toggle(
href="#",

View file

@ -0,0 +1,11 @@
.col-xs-8
span.v1-badge(aria-label=translate("v1_badge"))
span
if settings.overleaf && settings.overleaf.host
a.projectName(
href=settings.overleaf.host + "/{{project.id}}"
stop-propagation="click"
) {{project.name}}
.col-xs-4
span.last-modified {{project.lastUpdated | formatDate}}

View file

@ -35,7 +35,7 @@ define [
url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
return url
App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, labels, graphics, preamble, $http) ->
App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, labels, graphics, preamble, $http, $q) ->
monkeyPatchSearch($rootScope, $compile)
return {
@ -97,7 +97,7 @@ define [
if scope.spellCheck # only enable spellcheck when explicitly required
spellCheckCache = $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000})
spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache, $http)
spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache, $http, $q)
undoManager = new UndoManager(scope, editor, element)
highlightsManager = new HighlightsManager(scope, editor, element)
cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
@ -356,6 +356,7 @@ define [
session.setOption("useWorker", scope.syntaxValidation);
# now attach session to editor
editor.setReadOnly(true) # set to readonly until document change handlers are attached
editor.setSession(session)
doc = session.getDocument()
@ -364,6 +365,8 @@ define [
editor.initing = true
sharejs_doc.attachToAce(editor)
editor.initing = false
# now ready to edit document
editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
resetScrollMargins()

View file

@ -149,7 +149,7 @@ define [
lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
lastTwoChars = lineUpToCursor.slice(-2)
# Don't offer autocomplete on double-backslash, backslash-colon, etc
if lastTwoChars.match(/^\\[^a-z]$/)
if lastTwoChars.match(/^\\[^a-zA-Z]$/)
@editor?.completer?.detach?()
return
if commandName in ['begin', 'end']
@ -229,8 +229,9 @@ define [
99999
)
)
if lineBeyondCursor
if partialCommandMatch = lineBeyondCursor.match(/^([a-z0-9]+)\{/)
if partialCommandMatch = lineBeyondCursor.match(/^([a-zA-Z0-9]+)\{/)
# We've got a partial command after the cursor
commandTail = partialCommandMatch[1]
# remove rest of the partial command, right of cursor

View file

@ -17,8 +17,8 @@ define [
# \includegraphics[width=\textwidth]{..
# should not match the \textwidth.
blankArguments = lineUpToCursor.replace /\[([^\]]*)\]/g, (args) ->
Array(args.length+1).join('.')
if m = blankArguments.match(/(\\[^\\]+)$/)
Array(args.length + 1).join('.')
if m = blankArguments.match(/(\\[^\\]*)$/)
return m.index
else
return -1

View file

@ -5,9 +5,9 @@ define [
Range = ace.require("ace/range").Range
class SpellCheckManager
constructor: (@$scope, @editor, @element, @cache, @$http) ->
constructor: (@$scope, @editor, @element, @cache, @$http, @$q) ->
$(document.body).append @element.find(".spell-check-menu")
@inProgressRequest = null
@updatedLines = []
@highlightedWordManager = new HighlightedWordManager(@editor)
@ -23,8 +23,8 @@ define [
@editor.on "changeSession", (e) =>
@highlightedWordManager.reset()
# if @inProgressRequest?
# @inProgressRequest.abort()
if @inProgressRequest?
@inProgressRequest.abort()
if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
@runSpellCheckSoon(200)
@ -235,11 +235,18 @@ define [
apiRequest: (endpoint, data, callback = (error, result) ->)->
data.token = window.user.id
data._csrf = window.csrfToken
@$http.post("/spelling" + endpoint, data)
# use angular timeout option to cancel request if doc is changed
requestHandler = @$q.defer()
options = {timeout: requestHandler.promise}
httpRequest = @$http.post("/spelling" + endpoint, data, options)
.then (response) =>
callback(null, response.data)
.catch (response) =>
callback(new Error('api failure'))
# provide a method to cancel the request
abortRequest = () ->
requestHandler.resolve()
return { abort: abortRequest }
blacklistedCommandRegex: ///
\\ # initial backslash

View file

@ -85,7 +85,11 @@ define [
isTimeNonMonotonic = timeSinceLastCompile < 0
if isTimeNonMonotonic || timeSinceLastCompile >= AUTO_COMPILE_TIMEOUT
if (!ide.$scope.hasLintingError)
# If user has code check disabled, it is likely because they have
# linting errors that they are ignoring. Therefore it doesn't make sense
# to block auto compiles. It also causes problems where server-provided
# linting errors aren't cleared after typing
if (ide.$scope.settings.syntaxValidation and !ide.$scope.hasLintingError)
$scope.recompile(isAutoCompileOnChange: true)
else
# Extend remainder of timeout

View file

@ -116,6 +116,10 @@ define [
if $scope.filter == "shared" and project.accessLevel == "owner"
visible = false
# Hide projects from V1 if we only want to see shared projects
if $scope.filter == "shared" and project.isV1Project
visible = false
# Hide projects we don't own if we only want to see owned projects
if $scope.filter == "owned" and project.accessLevel != "owner"
visible = false
@ -129,6 +133,9 @@ define [
if project.archived
visible = false
if $scope.filter == "v1" and !project.isV1Project
visible = false
if visible
$scope.visibleProjects.push project
else

View file

@ -78,6 +78,7 @@
@import "app/invite.less";
@import "app/review-features-page.less";
@import "app/error-pages.less";
@import "app/v1-badge.less";
@import "../js/libs/pdfListView/TextLayer.css";
@import "../js/libs/pdfListView/AnnotationsLayer.css";

View file

@ -366,6 +366,11 @@ ul.project-list {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.v1-badge {
margin-right: 9px;
margin-left: 7px;
}
}
i.tablesort {
padding-left: 8px;

View file

@ -0,0 +1,10 @@
.v1-badge {
&:extend(.label);
&:extend(.label-default);
vertical-align: 11%;
padding: 1px 3px;
margin: 0 6px;
&:before {
content: "V1";
}
}

View file

@ -66,6 +66,9 @@ describe "ProjectController", ->
protectTokens: sinon.stub()
@CollaboratorsHandler =
userIsTokenMember: sinon.stub().callsArgWith(2, null, false)
@Modules =
hooks:
fire: sinon.stub()
@ProjectController = SandboxedModule.require modulePath, requires:
"settings-sharelatex":@settings
"logger-sharelatex":
@ -93,6 +96,7 @@ describe "ProjectController", ->
"../Analytics/AnalyticsManager": @AnalyticsManager
"../TokenAccess/TokenAccessHandler": @TokenAccessHandler
"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
"../../infrastructure/Modules": @Modules
@projectName = "£12321jkj9ujkljds"
@req =
@ -263,6 +267,7 @@ describe "ProjectController", ->
@TagsHandler.getAllTags.callsArgWith(1, null, @tags, {})
@NotificationsHandler.getUserNotifications = sinon.stub().callsArgWith(1, null, @notifications, {})
@ProjectGetter.findAllUsersProjects.callsArgWith(2, null, @allProjects)
@Modules.hooks.fire.withArgs('findAllV1Projects', @user._id).yields(undefined) # Without integration module hook, cb returns undefined
it "should render the project/list page", (done)->
@res.render = (pageName, opts)=>
@ -295,6 +300,53 @@ describe "ProjectController", ->
done()
@ProjectController.projectListPage @req, @res
describe 'with overleaf-integration-web-module hook', ->
beforeEach ->
@V1Response =
projects: [
{ id: '123mockV1Id', title: 'mock title', updated_at: 1509616411, removed: false, archived: false }
{ id: '456mockV1Id', title: 'mock title 2', updated_at: 1509616411, removed: true, archived: false }
],
tags: [
{ name: 'mock tag', project_ids: ['123mockV1Id'] }
]
@Modules.hooks.fire.withArgs('findAllV1Projects', @user._id).yields(null, [@V1Response]) # Need to wrap response in array, as multiple hooks could fire
it 'should include V1 projects', (done) ->
@res.render = (pageName, opts) =>
opts.projects.length.should.equal (
@projects.length +
@collabertions.length +
@readOnly.length +
@tokenReadAndWrite.length +
@tokenReadOnly.length +
@V1Response.projects.length
)
opts.projects.forEach (p) ->
# Check properties correctly mapped from V1
expect(p).to.have.property 'id'
expect(p).to.have.property 'name'
expect(p).to.have.property 'lastUpdated'
expect(p).to.have.property 'accessLevel'
expect(p).to.have.property 'archived'
done()
@ProjectController.projectListPage @req, @res
it 'should include V1 tags', (done) ->
@res.render = (pageName, opts) =>
opts.tags.length.should.equal (@tags.length + @V1Response.tags.length)
opts.tags.forEach (t) ->
expect(t).to.have.property 'name'
expect(t).to.have.property 'project_ids'
done()
@ProjectController.projectListPage @req, @res
it 'should have isShowingV1Projects flag', (done) ->
@res.render = (pageName, opts) =>
opts.isShowingV1Projects.should.equal true
done()
@ProjectController.projectListPage @req, @res
describe "projectListPage with duplicate projects", ->
beforeEach ->
@ -338,6 +390,7 @@ describe "ProjectController", ->
@TagsHandler.getAllTags.callsArgWith(1, null, @tags, {})
@NotificationsHandler.getUserNotifications = sinon.stub().callsArgWith(1, null, @notifications, {})
@ProjectGetter.findAllUsersProjects.callsArgWith(2, null, @allProjects)
@Modules.hooks.fire.withArgs('findAllV1Projects', @user._id).yields(undefined) # Without integration module hook, cb returns undefined
it "should render the project/list page", (done)->
@res.render = (pageName, opts)=>

View file

@ -187,6 +187,7 @@ describe "UserController", ->
describe "updateUserSettings", ->
beforeEach ->
@newEmail = "hello@world.com"
@req.externalAuthenticationSystemUsed = sinon.stub().returns(false)
it "should call save", (done)->
@req.body = {}
@ -280,10 +281,7 @@ describe "UserController", ->
beforeEach ->
@UserUpdater.changeEmailAddress.callsArgWith(2)
@newEmail = 'someone23@example.com'
@settings.ldap = {active: true}
afterEach ->
delete @settings.ldap
@req.externalAuthenticationSystemUsed = sinon.stub().returns(true)
it 'should not set a new email', (done) ->
@req.body.email = @newEmail

View file

@ -33,23 +33,19 @@ describe "Opening", ->
return done(err)
logger.log "smoke test: clearing rate limit "
require("../../../app/js/infrastructure/RateLimiter.js").clearRateLimit "open-project", "#{Settings.smokeTest.projectId}:#{Settings.smokeTest.userId}", ->
logger.log "smoke test: hitting /register"
logger.log "smoke test: hitting dev/csrf"
command = """
curl -H "X-Forwarded-Proto: https" -c #{cookeFilePath} #{buildUrl('register')}
curl -H "X-Forwarded-Proto: https" -c #{cookeFilePath} #{buildUrl('dev/csrf')}
"""
child.exec command, (err, stdout, stderr)->
if err? then done(err)
csrfMatches = stdout.match("<input name=\"_csrf\" type=\"hidden\" value=\"(.*?)\">")
if !csrfMatches?
logger.err stdout:stdout, "smoke test: does not have csrf token"
return done("smoke test: does not have csrf token")
csrf = csrfMatches[1]
csrf = stdout
logger.log "smoke test: converting cookie file 1"
convertCookieFile (err) ->
return done(err) if err?
logger.log "smoke test: hitting /register with csrf"
logger.log "smoke test: hitting /login with csrf"
command = """
curl -c #{cookeFilePath} -H "Content-Type: application/json" -H "X-Forwarded-Proto: https" -d '{"_csrf":"#{csrf}", "email":"#{Settings.smokeTest.user}", "password":"#{Settings.smokeTest.password}"}' #{buildUrl('register')}
curl -c #{cookeFilePath} -H "Content-Type: application/json" -H "X-Forwarded-Proto: https" -d '{"_csrf":"#{csrf}", "email":"#{Settings.smokeTest.user}", "password":"#{Settings.smokeTest.password}"}' #{buildUrl('login')}
"""
child.exec command, (err) ->
return done(err) if err?