Merge branch 'master' into node-4.2

This commit is contained in:
Henry Oswald 2016-07-28 13:54:06 +01:00
commit 23cb6a9419
172 changed files with 8661 additions and 6014 deletions

View file

@ -7,18 +7,69 @@ module.exports = (grunt) ->
grunt.loadNpmTasks 'grunt-mocha-test'
grunt.loadNpmTasks 'grunt-available-tasks'
grunt.loadNpmTasks 'grunt-contrib-requirejs'
grunt.loadNpmTasks 'grunt-execute'
grunt.loadNpmTasks 'grunt-bunyan'
grunt.loadNpmTasks 'grunt-sed'
grunt.loadNpmTasks 'grunt-git-rev-parse'
grunt.loadNpmTasks 'grunt-file-append'
grunt.loadNpmTasks 'grunt-file-append'
grunt.loadNpmTasks 'grunt-env'
grunt.loadNpmTasks 'grunt-newer'
grunt.loadNpmTasks 'grunt-contrib-watch'
grunt.loadNpmTasks 'grunt-parallel'
grunt.loadNpmTasks 'grunt-exec'
# grunt.loadNpmTasks 'grunt-contrib-imagemin'
# grunt.loadNpmTasks 'grunt-sprity'
config =
execute:
app:
src: "app.js"
exec:
run:
command:"node app.js | ./node_modules/logger-sharelatex/node_modules/bunyan/bin/bunyan --color"
cssmin:
command:"node_modules/clean-css/bin/cleancss --s0 -o public/stylesheets/style.css public/stylesheets/style.css"
watch:
coffee:
files: 'public/**/*.coffee'
tasks: ['quickcompile:coffee']
options: {}
less:
files: '**/*.less'
tasks: ['compile:css']
options: {}
parallel:
run:
tasks:['exec', 'watch']
options:
grunt:true
stream:true
# imagemin:
# dynamic:
# files: [{
# expand: true
# cwd: 'public/img/'
# src: ['**/*.{png,jpg,gif}']
# dest: 'public/img/'
# }]
# options:
# interlaced:false
# optimizationLevel: 7
# sprity:
# sprite:
# options:
# cssPath:"/img/"
# 'style': '../../public/stylesheets/app/sprites.less'
# margin: 0
# src: ['./public/img/flags/24/*.png']
# dest: './public/img/sprite'
coffee:
app_dir:
@ -84,11 +135,16 @@ module.exports = (grunt) ->
files:
"public/stylesheets/style.css": "public/stylesheets/style.less"
env:
run:
add:
NODE_TLS_REJECT_UNAUTHORIZED:0
requirejs:
compile:
options:
@ -170,6 +226,9 @@ module.exports = (grunt) ->
pattern: "@@RELEASE@@"
replacement: process.env.BUILD_NUMBER || "(unknown build)"
availabletasks:
tasks:
options:
@ -304,12 +363,14 @@ module.exports = (grunt) ->
grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append"]
grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",]
grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']
grunt.registerTask 'compile:smoke_tests', 'Compile the smoke tests', ['coffee:smoke_tests']
grunt.registerTask 'compile:tests', 'Compile all the tests', ['compile:smoke_tests', 'compile:unit_tests']
grunt.registerTask 'compile', 'Compiles everything need to run web-sharelatex', ['compile:server', 'compile:client', 'compile:css']
grunt.registerTask 'quickcompile:coffee', 'Compiles only changed coffee files',['newer:coffee']
grunt.registerTask 'install', "Compile everything when installing as an npm module", ['compile']
@ -319,11 +380,13 @@ module.exports = (grunt) ->
grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)
grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'bunyan', 'env:run', 'execute']
grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
grunt.registerTask 'default', 'run'
grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed']
grunt.registerTask 'create-admin-user', "Create a user with the given email address and make them an admin. Update in place if the user already exists", () ->
done = @async()
email = grunt.option("email")
@ -357,3 +420,23 @@ module.exports = (grunt) ->
"""
done()
grunt.registerTask 'delete-user', "deletes a user and all their data", () ->
done = @async()
email = grunt.option("email")
if !email?
console.error "Usage: grunt delete-user --email joe@example.com"
process.exit(1)
settings = require "settings-sharelatex"
UserGetter = require "./app/js/Features/User/UserGetter"
UserDeleter = require "./app/js/Features/User/UserDeleter"
UserGetter.getUser email:email, (error, user) ->
if error?
throw error
if !user?
console.log("user #{email} not in database, potentially already deleted")
return done()
UserDeleter.deleteUser user._id, (err)->
if err?
throw err
done()

View file

@ -6,9 +6,17 @@ web-sharelatex is the front-end web service of the open-source web-based collabo
It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains
a lot of logic around creating and editing projects, and account management.
The rest of the ShareLaTeX stack, along with information about contributing can be found in the
[sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
Build process
----------------
web-sharelatex uses [Grunt](http://gruntjs.com/) to build its front-end related assets.
Image processing tasks are commented out in the gruntfile and the needed packages aren't presently in the project's `package.json`. If the images need to be processed again (minified and sprited), start by fetching the packages (`npm install grunt-contrib-imagemin grunt-sprity`), then *decomment* the tasks in `Gruntfile.coffee`. After this, the tasks can be called (explicitly, via `grunt imagemin` and `grunt sprity`).
Unit test status
----------------
@ -42,3 +50,11 @@ in the `public/img/iconshock` directory found via
[findicons.com](http://findicons.com/icon/498089/height?id=526085#)
## Acceptance Tests
To run the Acceptance tests:
- set `allowPublicAccess` to true, either in the configuration file,
or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
- start the server (`grunt`)
- in a separate terminal, run `grunt test:acceptance`

View file

@ -9,6 +9,7 @@ Url = require("url")
Settings = require "settings-sharelatex"
basicAuth = require('basic-auth-connect')
UserHandler = require("../User/UserHandler")
UserSessionsManager = require("../User/UserSessionsManager")
module.exports = AuthenticationController =
login: (req, res, next = (error) ->) ->
@ -132,6 +133,8 @@ module.exports = AuthenticationController =
isAdmin: user.isAdmin
email: user.email
referal_id: user.referal_id
session_created: (new Date()).toISOString()
ip_address: req.ip
# Regenerate the session to get a new sessionID (cookie value) to
# protect against session fixation attacks
oldSession = req.session
@ -141,4 +144,6 @@ module.exports = AuthenticationController =
req.session[key] = value
req.session.user = lightUser
UserSessionsManager.trackSession(user, req.sessionID, () ->)
callback()

View file

@ -1,8 +1,11 @@
Settings = require "settings-sharelatex"
User = require("../../models/User").User
{db, ObjectId} = require("../../infrastructure/mongojs")
crypto = require 'crypto'
bcrypt = require 'bcrypt'
BCRYPT_ROUNDS = Settings?.security?.bcryptRounds or 12
module.exports = AuthenticationManager =
authenticate: (query, password, callback = (error, user) ->) ->
# Using Mongoose for legacy reasons here. The returned User instance
@ -15,6 +18,8 @@ module.exports = AuthenticationManager =
bcrypt.compare password, user.hashedPassword, (error, match) ->
return callback(error) if error?
if match
AuthenticationManager.checkRounds user, user.hashedPassword, password, (err) ->
return callback(err) if err?
callback null, user
else
callback null, null
@ -24,7 +29,7 @@ module.exports = AuthenticationManager =
callback null, null
setUserPassword: (user_id, password, callback = (error) ->) ->
bcrypt.genSalt 7, (error, salt) ->
bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
return callback(error) if error?
bcrypt.hash password, salt, (error, hash) ->
return callback(error) if error?
@ -35,3 +40,10 @@ module.exports = AuthenticationManager =
$unset: password: true
}, callback)
checkRounds: (user, hashedPassword, password, callback = (error) ->) ->
# check current number of rounds and rehash if necessary
currentRounds = bcrypt.getRounds hashedPassword
if currentRounds < BCRYPT_ROUNDS
AuthenticationManager.setUserPassword user._id, password, callback
else
callback()

View file

@ -0,0 +1,39 @@
BetaProgramHandler = require './BetaProgramHandler'
UserLocator = require "../User/UserLocator"
Settings = require "settings-sharelatex"
logger = require 'logger-sharelatex'
module.exports = BetaProgramController =
optIn: (req, res, next) ->
user_id = req?.session?.user?._id
logger.log {user_id}, "user opting in to beta program"
if !user_id
return next(new Error("no user id in session"))
BetaProgramHandler.optIn user_id, (err) ->
if err
return next(err)
return res.redirect "/beta/participate"
optOut: (req, res, next) ->
user_id = req?.session?.user?._id
logger.log {user_id}, "user opting out of beta program"
if !user_id
return next(new Error("no user id in session"))
BetaProgramHandler.optOut user_id, (err) ->
if err
return next(err)
return res.redirect "/beta/participate"
optInPage: (req, res, next)->
user_id = req.session?.user?._id
logger.log {user_id}, "showing beta participation page for user"
UserLocator.findById user_id, (err, user)->
if err
logger.err {err, user_id}, "error fetching user"
return next(err)
res.render 'beta_program/opt_in',
title:'sharelatex_beta_program'
user: user,
languages: Settings.languages,

View file

@ -0,0 +1,31 @@
User = require("../../models/User").User
logger = require 'logger-sharelatex'
metrics = require("../../infrastructure/Metrics")
module.exports = BetaProgramHandler =
optIn: (user_id, callback=(err)->) ->
User.findById user_id, (err, user) ->
if err
logger.err {err, user_id}, "problem adding user to beta"
return callback(err)
metrics.inc "beta-program.opt-in"
user.betaProgram = true
user.save (err) ->
if err
logger.err {err, user_id}, "problem adding user to beta"
return callback(err)
return callback(null)
optOut: (user_id, callback=(err)->) ->
User.findById user_id, (err, user) ->
if err
logger.err {err, user_id}, "problem removing user from beta"
return callback(err)
metrics.inc "beta-program.opt-out"
user.betaProgram = false
user.save (err) ->
if err
logger.err {err, user_id}, "problem removing user from beta"
return callback(err)
return callback(null)

View file

@ -20,13 +20,15 @@ module.exports = BlogController =
logger.log url:url, "proxying request to blog api"
request.get blogUrl, (err, r, data)->
if r?.statusCode == 404
if r?.statusCode == 404 or r?.statusCode == 403
return ErrorController.notFound(req, res, next)
if err?
return res.send 500
data = data.trim()
try
data = JSON.parse(data)
if settings.cdn?.web?.host?
data?.content = data?.content?.replace(/src="([^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
catch err
logger.err err:err, data:data, "error parsing data from data"
res.render "blog/blog_holder", data

View file

@ -30,12 +30,17 @@ module.exports = CollaboratorsHandler =
getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
return callback(error) if error?
result = []
async.mapLimit members, 3,
(member, cb) ->
UserGetter.getUser member.id, (error, user) ->
return cb(error) if error?
return cb(null, { user: user, privilegeLevel: member.privilegeLevel })
callback
if user?
result.push { user: user, privilegeLevel: member.privilegeLevel }
cb()
(error) ->
return callback(error) if error?
callback null, result
getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
# In future if the schema changes and getting all member ids is more expensive (multiple documents)
@ -82,6 +87,18 @@ module.exports = CollaboratorsHandler =
logger.error err: err, "problem removing user from project collaberators"
callback(err)
removeUserFromAllProjets: (user_id, callback = (error) ->) ->
CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
return callback(error) if error?
allProjects = readAndWriteProjects.concat(readOnlyProjects)
jobs = []
for project in allProjects
do (project) ->
jobs.push (cb) ->
return cb() if !project?
CollaboratorsHandler.removeUserFromProject project._id, user_id, cb
async.series jobs, callback
addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
emails = mimelib.parseAddresses(unparsed_email)
email = emails[0]?.address?.toLowerCase()

View file

@ -0,0 +1,68 @@
_ = require("lodash")
async = require("async")
module.exports = ClsiFormatChecker =
checkRecoursesForProblems: (resources, callback)->
jobs =
conflictedPaths: (cb)->
ClsiFormatChecker._checkForConflictingPaths resources, cb
sizeCheck: (cb)->
ClsiFormatChecker._checkDocsAreUnderSizeLimit resources, cb
async.series jobs, (err, problems)->
if err?
return callback(err)
problems = _.omitBy(problems, _.isEmpty)
if _.isEmpty(problems)
return callback()
else
callback(null, problems)
_checkForConflictingPaths: (resources, callback)->
paths = _.map(resources, 'path')
conflicts = _.filter paths, (path)->
matchingPaths = _.filter paths, (checkPath)->
return checkPath.indexOf(path+"/") != -1
return matchingPaths.length > 0
conflictObjects = _.map conflicts, (conflict)->
path:conflict
callback null, conflictObjects
_checkDocsAreUnderSizeLimit: (resources, callback)->
FIVEMB = 1000 * 1000 * 5
totalSize = 0
sizedResources = _.map resources, (resource)->
result = {path:resource.path}
if resource.content?
result.size = resource.content.replace(/\n/g).length
result.kbSize = Math.ceil(result.size / 1000)
else
result.size = 0
totalSize += result.size
return result
tooLarge = totalSize > FIVEMB
if !tooLarge
return callback()
else
sizedResources = _.sortBy(sizedResources, "size").reverse().slice(0, 10)
return callback(null, {resources:sizedResources, totalSize:totalSize})

View file

@ -7,15 +7,24 @@ ProjectEntityHandler = require("../Project/ProjectEntityHandler")
logger = require "logger-sharelatex"
Url = require("url")
ClsiCookieManager = require("./ClsiCookieManager")
_ = require("underscore")
async = require("async")
ClsiFormatChecker = require("./ClsiFormatChecker")
module.exports = ClsiManager =
sendRequest: (project_id, options = {}, callback = (error, success) ->) ->
sendRequest: (project_id, user_id, options = {}, callback = (error, status, outputFiles, clsiServerId, validationProblems) ->) ->
ClsiManager._buildRequest project_id, options, (error, req) ->
return callback(error) if error?
logger.log project_id: project_id, "sending compile to CLSI"
ClsiManager._postToClsi project_id, req, options.compileGroup, (error, response) ->
ClsiFormatChecker.checkRecoursesForProblems req.compile?.resources, (err, validationProblems)->
if err?
logger.err err, project_id, "could not check resources for potential problems before sending to clsi"
return callback(err)
if validationProblems?
logger.log project_id:project_id, validationProblems:validationProblems, "problems with users latex before compile was attempted"
return callback(null, "validation-problems", null, null, validationProblems)
ClsiManager._postToClsi project_id, user_id, req, options.compileGroup, (error, response) ->
if error?
logger.err err:error, project_id:project_id, "error sending request to clsi"
return callback(error)
@ -27,13 +36,19 @@ module.exports = ClsiManager =
outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles)
callback(null, response?.compile?.status, outputFiles, clsiServerId)
deleteAuxFiles: (project_id, options, callback = (error) ->) ->
compilerUrl = @_getCompilerUrl(options?.compileGroup)
stopCompile: (project_id, user_id, options, callback = (error) ->) ->
compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id, "compile/stop")
opts =
url:"#{compilerUrl}/project/#{project_id}"
method:"DELETE"
url:compilerUrl
method:"POST"
ClsiManager._makeRequest project_id, opts, callback
deleteAuxFiles: (project_id, user_id, options, callback = (error) ->) ->
compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id)
opts =
url:compilerUrl
method:"DELETE"
ClsiManager._makeRequest project_id, opts, callback
_makeRequest: (project_id, opts, callback)->
ClsiCookieManager.getCookieJar project_id, (err, jar)->
@ -51,14 +66,17 @@ module.exports = ClsiManager =
return callback err, response, body
_getCompilerUrl: (compileGroup, project_id, user_id, action) ->
host = Settings.apis.clsi.url
path = "/project/#{project_id}"
path += "/user/#{user_id}" if user_id?
path += "/#{action}" if action?
return "#{host}#{path}"
_getCompilerUrl: (compileGroup) ->
return Settings.apis.clsi.url
_postToClsi: (project_id, req, compileGroup, callback = (error, response) ->) ->
compilerUrl = Settings.apis.clsi.url
_postToClsi: (project_id, user_id, req, compileGroup, callback = (error, response) ->) ->
compileUrl = @_getCompilerUrl(compileGroup, project_id, user_id, "compile")
opts =
url: "#{compilerUrl}/project/#{project_id}/compile"
url: compileUrl
json: req
method: "POST"
ClsiManager._makeRequest project_id, opts, (error, response, body) ->
@ -75,10 +93,9 @@ module.exports = ClsiManager =
_parseOutputFiles: (project_id, rawOutputFiles = []) ->
outputFiles = []
for file in rawOutputFiles
path = Url.parse(file.url).path
path = path.replace("/project/#{project_id}/output/", "")
outputFiles.push
path: path
path: file.path # the clsi is now sending this to web
url: Url.parse(file.url).path # the location of the file on the clsi, excluding the host part
type: file.type
build: file.build
return outputFiles
@ -134,15 +151,15 @@ module.exports = ClsiManager =
resources: resources
}
wordCount: (project_id, file, options, callback = (error, response) ->) ->
wordCount: (project_id, user_id, file, options, callback = (error, response) ->) ->
ClsiManager._buildRequest project_id, options, (error, req) ->
compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup)
filename = file || req?.compile?.rootResourcePath
wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}"
if req.compile.options.imageName?
wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}"
wordcount_url = ClsiManager._getCompilerUrl(options?.compileGroup, project_id, user_id, "wordcount")
opts =
url: wordcount_url
qs:
file: filename
image: req.compile.options.imageName
method: "GET"
ClsiManager._makeRequest project_id, opts, (error, response, body) ->
return callback(error) if error?

View file

@ -29,8 +29,8 @@ module.exports = CompileController =
options.compiler = req.body.compiler
if req.body?.draft
options.draft = req.body.draft
logger.log {options, project_id}, "got compile request"
CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits) ->
logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
return next(error) if error?
res.contentType("application/json")
res.status(200).send JSON.stringify {
@ -38,8 +38,32 @@ module.exports = CompileController =
outputFiles: outputFiles
compileGroup: limits?.compileGroup
clsiServerId:clsiServerId
validationProblems:validationProblems
}
stopCompile: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
AuthenticationController.getLoggedInUserId req, (error, user_id) ->
return next(error) if error?
logger.log {project_id:project_id, user_id:user_id}, "stop compile request"
CompileManager.stopCompile project_id, user_id, (error) ->
return next(error) if error?
res.status(200).send()
_compileAsUser: (req, callback) ->
# callback with user_id if per-user, undefined otherwise
if not Settings.disablePerUserCompiles
AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
else
callback() # do a per-project compile, not per-user
_downloadAsUser: (req, callback) ->
# callback with user_id if per-user, undefined otherwise
if not Settings.disablePerUserCompiles
AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
else
callback() # do a per-project compile, not per-user
downloadPdf: (req, res, next = (error) ->)->
Metrics.inc "pdf-downloads"
project_id = req.params.Project_id
@ -72,16 +96,22 @@ module.exports = CompileController =
logger.log project_id:project_id, ip:req.ip, "rate limit hit downloading pdf"
res.send 500
else
CompileController.proxyToClsi(project_id, "/project/#{project_id}/output/output.pdf", req, res, next)
CompileController._downloadAsUser req, (error, user_id) ->
url = CompileController._getFileUrl project_id, user_id, req.params.build_id, "output.pdf"
CompileController.proxyToClsi(project_id, url, req, res, next)
deleteAuxFiles: (req, res, next) ->
project_id = req.params.Project_id
CompileManager.deleteAuxFiles project_id, (error) ->
CompileController._compileAsUser req, (error, user_id) ->
return next(error) if error?
CompileManager.deleteAuxFiles project_id, user_id, (error) ->
return next(error) if error?
res.sendStatus(200)
# this is only used by templates, so is not called with a user_id
compileAndDownloadPdf: (req, res, next)->
project_id = req.params.project_id
# pass user_id as null, since templates are an "anonymous" compile
CompileManager.compile project_id, null, {}, (err)->
if err?
logger.err err:err, project_id:project_id, "something went wrong compile and downloading pdf"
@ -91,13 +121,29 @@ module.exports = CompileController =
getFileFromClsi: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
build = req.params.build
if build?
url = "/project/#{project_id}/build/#{build}/output/#{req.params.file}"
else
url = "/project/#{project_id}/output/#{req.params.file}"
CompileController._downloadAsUser req, (error, user_id) ->
return next(error) if error?
url = CompileController._getFileUrl project_id, user_id, req.params.build_id, req.params.file
CompileController.proxyToClsi(project_id, url, req, res, next)
# compute a GET file url for a given project, user (optional), build (optional) and file
_getFileUrl: (project_id, user_id, build_id, file) ->
if user_id? and build_id?
url = "/project/#{project_id}/user/#{user_id}/build/#{build_id}/output/#{file}"
else if user_id?
url = "/project/#{project_id}/user/#{user_id}/output/#{file}"
else if build_id?
url = "/project/#{project_id}/build/#{build_id}/output/#{file}"
else
url = "/project/#{project_id}/output/#{file}"
return url
# compute a POST url for a project, user (optional) and action
_getUrl: (project_id, user_id, action) ->
path = "/project/#{project_id}"
path += "/user/#{user_id}" if user_id?
return "#{path}/#{action}"
proxySyncPdf: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
{page, h, v} = req.query
@ -107,19 +153,34 @@ module.exports = CompileController =
return next(new Error("invalid h parameter"))
if not v?.match(/^\d+\.\d+$/)
return next(new Error("invalid v parameter"))
destination = {url: "/project/#{project_id}/sync/pdf", qs: {page, h, v}}
# whether this request is going to a per-user container
CompileController._compileAsUser req, (error, user_id) ->
return next(error) if error?
url = CompileController._getUrl(project_id, user_id, "sync/pdf")
destination = {url: url, qs: {page, h, v}}
CompileController.proxyToClsi(project_id, destination, req, res, next)
proxySyncCode: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
{file, line, column} = req.query
if not file? or Path.resolve("/", file) isnt "/#{file}"
if not file?
return next(new Error("missing file parameter"))
# Check that we are dealing with a simple file path (this is not
# strictly needed because synctex uses this parameter as a label
# to look up in the synctex output, and does not open the file
# itself). Since we have valid synctex paths like foo/./bar we
# allow those by replacing /./ with /
testPath = file.replace '/./', '/'
if Path.resolve("/", testPath) isnt "/#{testPath}"
return next(new Error("invalid file parameter"))
if not line?.match(/^\d+$/)
return next(new Error("invalid line parameter"))
if not column?.match(/^\d+$/)
return next(new Error("invalid column parameter"))
destination = {url:"/project/#{project_id}/sync/code", qs: {file, line, column}}
CompileController._compileAsUser req, (error, user_id) ->
return next(error) if error?
url = CompileController._getUrl(project_id, user_id, "sync/code")
destination = {url:url, qs: {file, line, column}}
CompileController.proxyToClsi(project_id, destination, req, res, next)
proxyToClsi: (project_id, url, req, res, next = (error) ->) ->
@ -138,9 +199,6 @@ module.exports = CompileController =
# expand any url parameter passed in as {url:..., qs:...}
if typeof url is "object"
{url, qs} = url
if limits.compileGroup == "priority"
compilerUrl = Settings.apis.clsi_priority.url
else
compilerUrl = Settings.apis.clsi.url
url = "#{compilerUrl}#{url}"
logger.log url: url, "proxying to CLSI"
@ -168,7 +226,9 @@ module.exports = CompileController =
wordCount: (req, res, next) ->
project_id = req.params.Project_id
file = req.query.file || false
CompileManager.wordCount project_id, file, (error, body) ->
CompileController._compileAsUser req, (error, user_id) ->
return next(error) if error?
CompileManager.wordCount project_id, user_id, file, (error, body) ->
return next(error) if error?
res.contentType("application/json")
res.send body

View file

@ -37,15 +37,23 @@ module.exports = CompileManager =
return callback(error) if error?
for key, value of limits
options[key] = value
ClsiManager.sendRequest project_id, options, (error, status, outputFiles, clsiServerId) ->
# only pass user_id down to clsi if this is a per-user compile
compileAsUser = if Settings.disablePerUserCompiles then undefined else user_id
ClsiManager.sendRequest project_id, compileAsUser, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
return callback(error) if error?
logger.log files: outputFiles, "output files"
callback(null, status, outputFiles, clsiServerId, limits)
callback(null, status, outputFiles, clsiServerId, limits, validationProblems)
deleteAuxFiles: (project_id, callback = (error) ->) ->
stopCompile: (project_id, user_id, callback = (error) ->) ->
CompileManager.getProjectCompileLimits project_id, (error, limits) ->
return callback(error) if error?
ClsiManager.deleteAuxFiles project_id, limits, callback
ClsiManager.stopCompile project_id, user_id, limits, callback
deleteAuxFiles: (project_id, user_id, callback = (error) ->) ->
CompileManager.getProjectCompileLimits project_id, (error, limits) ->
return callback(error) if error?
ClsiManager.deleteAuxFiles project_id, user_id, limits, callback
getProjectCompileLimits: (project_id, callback = (error, limits) ->) ->
Project.findById project_id, {owner_ref: 1}, (error, project) ->
@ -92,7 +100,7 @@ module.exports = CompileManager =
else
ProjectRootDocManager.setRootDocAutomatically project_id, callback
wordCount: (project_id, file, callback = (error) ->) ->
wordCount: (project_id, user_id, file, callback = (error) ->) ->
CompileManager.getProjectCompileLimits project_id, (error, limits) ->
return callback(error) if error?
ClsiManager.wordCount project_id, file, limits, callback
ClsiManager.wordCount project_id, user_id, file, limits, callback

View file

@ -3,6 +3,8 @@ PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
settings = require("settings-sharelatex")
templates = {}
templates.registered =
@ -114,6 +116,8 @@ module.exports =
template = templates[templateName]
opts.siteUrl = settings.siteUrl
opts.body = template.compiledTemplate(opts)
if settings.email?.templates?.customFooter?
opts.body += settings.email?.templates?.customFooter
return {
subject : template.subject(opts)
html: template.layout(opts)

View file

@ -1,15 +1,15 @@
logger = require("logger-sharelatex")
NotificationsHandler = require("./NotificationsHandler")
module.exports =
groupPlan: (user, licence)->
key : "join-sub-#{licence.subscription_id}"
create: (callback = ->)->
messageOpts =
groupName: licence.name
subscription_id: licence.subscription_id
logger.log user_id:user._id, key:key, "creating notification key for user"
NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback
read: (callback = ->)->

View file

@ -3,14 +3,22 @@ request = require("request")
logger = require("logger-sharelatex")
oneSecond = 1000
makeRequest = (opts, callback)->
if !settings.apis.notifications?.url?
return callback(null, statusCode:200)
else
request(opts, callback)
module.exports =
getUserNotifications: (user_id, callback)->
opts =
uri: "#{settings.apis.notifications.url}/user/#{user_id}"
uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
json: true
timeout: oneSecond
request.get opts, (err, res, unreadNotifications)->
method: "GET"
makeRequest opts, (err, res, unreadNotifications)->
statusCode = if res? then res.statusCode else 500
if err? or statusCode != 200
e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
@ -23,30 +31,33 @@ module.exports =
createNotification: (user_id, key, templateKey, messageOpts, callback)->
opts =
uri: "#{settings.apis.notifications.url}/user/#{user_id}"
uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
timeout: oneSecond
method:"POST"
json: {
key:key
messageOpts:messageOpts
templateKey:templateKey
}
logger.log opts:opts, "creating notification for user"
request.post opts, callback
makeRequest opts, callback
markAsReadWithKey: (user_id, key, callback)->
opts =
uri: "#{settings.apis.notifications.url}/user/#{user_id}"
uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
method: "DELETE"
timeout: oneSecond
json: {
key:key
}
logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
request.del opts, callback
makeRequest opts, callback
markAsRead: (user_id, notification_id, callback)->
opts =
uri: "#{settings.apis.notifications.url}/user/#{user_id}/notification/#{notification_id}"
method: "DELETE"
uri: "#{settings.apis.notifications?.url}/user/#{user_id}/notification/#{notification_id}"
timeout:oneSecond
logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
request.del opts, callback
makeRequest opts, callback

View file

@ -2,6 +2,7 @@ PasswordResetHandler = require("./PasswordResetHandler")
RateLimiter = require("../../infrastructure/RateLimiter")
AuthenticationController = require("../Authentication/AuthenticationController")
UserGetter = require("../User/UserGetter")
UserSessionsManager = require("../User/UserSessionsManager")
logger = require "logger-sharelatex"
module.exports =
@ -47,6 +48,8 @@ module.exports =
PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
return next(err) if err?
if found
UserSessionsManager.revokeAllUserSessions {_id: user_id}, [], (err) ->
return next(err) if err?
if req.body.login_after
UserGetter.getUser user_id, {email: 1}, (err, user) ->
return next(err) if err?

View file

@ -245,11 +245,13 @@ module.exports = ProjectController =
first_name : user.first_name
last_name : user.last_name
referal_id : user.referal_id
signUpDate : user.signUpDate
subscription :
freeTrial: {allowed: allowedFreeTrial}
featureSwitches: user.featureSwitches
features: user.features
refProviders: user.refProviders
betaProgram: user.betaProgram
}
userSettings: {
mode : user.ace.mode

View file

@ -24,9 +24,11 @@ module.exports = ProjectDeleter =
update = {deletedByExternalDataSource: false}
Project.update conditions, update, {}, callback
deleteUsersProjects: (owner_id, callback)->
logger.log owner_id:owner_id, "deleting users projects"
Project.remove owner_ref:owner_id, callback
deleteUsersProjects: (user_id, callback)->
logger.log {user_id}, "deleting users projects"
Project.remove owner_ref:user_id, (error) ->
return callback(error) if error?
CollaboratorsHandler.removeUserFromAllProjets user_id, callback
deleteProject: (project_id, callback = (error) ->) ->
# archiveProject takes care of the clean-up

View file

@ -512,6 +512,11 @@ module.exports = ProjectEntityHandler =
return callback(e)
type = sanitizeTypeOfElement type
if path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
e = new Error("invalid element name")
logger.err project_id:project._id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as name was invalid"
return callback(e)
if !folder_id?
folder_id = project.rootFolder[0]._id
ProjectEntityHandler._countElements project, (err, count)->

View file

@ -15,7 +15,7 @@ module.exports = ReferencesController =
return res.sendStatus 400
logger.log {projectId, docIds: docIds}, "index references for project"
ReferencesHandler.index projectId, docIds, (err, data) ->
if err
if err?
logger.err {err, projectId}, "error indexing all references"
return res.sendStatus 500
ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
@ -25,12 +25,14 @@ module.exports = ReferencesController =
shouldBroadcast = req.body.shouldBroadcast
logger.log {projectId}, "index all references for project"
ReferencesHandler.indexAll projectId, (err, data) ->
if err
if err?
logger.err {err, projectId}, "error indexing all references"
return res.sendStatus 500
ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
_handleIndexResponse: (req, res, projectId, shouldBroadcast, data) ->
if !data? or !data.keys?
return res.json({projectId, keys: []})
if shouldBroadcast
logger.log {projectId}, "emitting new references keys to connected clients"
EditorRealTimeController.emitToRoom projectId, 'references:keys:updated', data.keys

View file

@ -66,6 +66,8 @@ module.exports = ReferencesHandler =
ReferencesHandler._doIndexOperation(projectId, project, docIds, [], callback)
_doIndexOperation: (projectId, project, docIds, fileIds, callback) ->
if !settings.apis?.references?.url?
return callback()
ReferencesHandler._isFullIndex project, (err, isFullIndex) ->
if err
logger.err {err, projectId}, "error checking whether to do full index"

View file

@ -5,6 +5,12 @@ _ = require("underscore")
ErrorController = require "../Errors/ErrorController"
StaticPageHelpers = require("./StaticPageHelpers")
sanitize = require('sanitizer')
Settings = require("settings-sharelatex")
contentful = require('contentful')
marked = require("marked")
module.exports = UniversityController =
@ -17,7 +23,7 @@ module.exports = UniversityController =
logger.log url:url, "proxying request to university api"
request.get universityUrl, (err, r, data)->
if r?.statusCode == 404
return ErrorController.notFound(req, res, next)
return UniversityController.getContentfulPage(req, res, next)
if err?
return res.send 500
data = data.trim()
@ -38,3 +44,27 @@ module.exports = UniversityController =
upstream.on "error", (error) ->
logger.error err: error, "university proxy error"
upstream.pipe res
getContentfulPage: (req, res, next)->
console.log Settings.contentful
if !Settings.contentful?.uni?.space? and !Settings.contentful?.uni?.accessToken?
return ErrorController.notFound(req, res, next)
client = contentful.createClient({
space: Settings.contentful?.uni?.space
accessToken: Settings.contentful?.uni?.accessToken
})
url = req.url?.toLowerCase().replace("/university/","")
client.getEntries({content_type: 'caseStudy', 'fields.slug':url})
.catch (e)->
return res.send 500
.then (entry)->
if !entry? or !entry.items? or entry.items.length == 0
return ErrorController.notFound(req, res, next)
viewData = entry.items[0].fields
viewData.html = marked(viewData.content)
res.render "university/case_study", viewData:viewData

View file

@ -38,12 +38,12 @@ module.exports =
SubscriptionLocator.getUsersSubscription user._id, (err, subscription)->
if err?
return callback(err)
hasValidSubscription = subscription? and subscription.recurlySubscription_id?
hasValidSubscription = subscription? and (subscription.recurlySubscription_id? or subscription?.customAccount == true)
logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription"
callback err, hasValidSubscription, subscription
userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) ->
logger.log user_id: user._ud, "checking is user is member of subscription groups"
logger.log user_id: user._id, "checking is user is member of subscription groups"
SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) ->
return callback(err) if err?
callback err, subscriptions.length > 0, subscriptions

View file

@ -4,11 +4,201 @@ request = require 'request'
Settings = require "settings-sharelatex"
xml2js = require "xml2js"
logger = require("logger-sharelatex")
Async = require('async')
module.exports = RecurlyWrapper =
apiUrl : "https://api.recurly.com/v2"
createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
_addressToXml: (address) ->
allowedKeys = ['address1', 'address2', 'city', 'country', 'state', 'zip', 'postal_code']
resultString = "<billing_info>\n"
for k, v of address
if k == 'postal_code'
k = 'zip'
if v and (k in allowedKeys)
resultString += "<#{k}#{if k == 'address2' then ' nil="nil"' else ''}>#{v || ''}</#{k}>\n"
resultString += "</billing_info>\n"
return resultString
_paypal:
checkAccountExists: (cache, next) ->
user = cache.user
recurly_token_id = cache.recurly_token_id
subscriptionDetails = cache.subscriptionDetails
logger.log {user_id: user._id, recurly_token_id}, "checking if recurly account exists for user"
RecurlyWrapper.apiRequest({
url: "accounts/#{user._id}"
method: "GET"
}, (error, response, responseBody) ->
if error
if response.statusCode == 404 # actually not an error in this case, just no existing account
cache.userExists = false
return next(null, cache)
logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while checking account"
return next(error)
logger.log {user_id: user._id, recurly_token_id}, "user appears to exist in recurly"
RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
if err
logger.error {err, user_id: user._id, recurly_token_id}, "error parsing account"
return next(err)
cache.userExists = true
cache.account = account
return next(null, cache)
)
createAccount: (cache, next) ->
user = cache.user
recurly_token_id = cache.recurly_token_id
subscriptionDetails = cache.subscriptionDetails
address = subscriptionDetails.address
if !address
return next(new Error('no address in subscriptionDetails at createAccount stage'))
if cache.userExists
logger.log {user_id: user._id, recurly_token_id}, "user already exists in recurly"
return next(null, cache)
logger.log {user_id: user._id, recurly_token_id}, "creating user in recurly"
requestBody = """
<account>
<account_code>#{user._id}</account_code>
<email>#{user.email}</email>
<first_name>#{user.first_name}</first_name>
<last_name>#{user.last_name}</last_name>
<address>
<address1>#{address.address1}</address1>
<address2>#{address.address2}</address2>
<city>#{address.city || ''}</city>
<state>#{address.state || ''}</state>
<zip>#{address.zip || ''}</zip>
<country>#{address.country}</country>
</address>
</account>
"""
RecurlyWrapper.apiRequest({
url : "accounts"
method : "POST"
body : requestBody
}, (error, response, responseBody) =>
if error
logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating account"
return next(error)
RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
if err
logger.error {err, user_id: user._id, recurly_token_id}, "error creating account"
return next(err)
cache.account = account
return next(null, cache)
)
createBillingInfo: (cache, next) ->
user = cache.user
recurly_token_id = cache.recurly_token_id
subscriptionDetails = cache.subscriptionDetails
logger.log {user_id: user._id, recurly_token_id}, "creating billing info in recurly"
accountCode = cache?.account?.account_code
if !accountCode
return next(new Error('no account code at createBillingInfo stage'))
requestBody = """
<billing_info>
<token_id>#{recurly_token_id}</token_id>
</billing_info>
"""
RecurlyWrapper.apiRequest({
url: "accounts/#{accountCode}/billing_info"
method: "POST"
body: requestBody
}, (error, response, responseBody) =>
if error
logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating billing info"
return next(error)
RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) ->
if err
logger.error {err, user_id: user._id, accountCode, recurly_token_id}, "error creating billing info"
return next(err)
cache.billingInfo = billingInfo
return next(null, cache)
)
setAddress: (cache, next) ->
user = cache.user
recurly_token_id = cache.recurly_token_id
subscriptionDetails = cache.subscriptionDetails
logger.log {user_id: user._id, recurly_token_id}, "setting billing address in recurly"
accountCode = cache?.account?.account_code
if !accountCode
return next(new Error('no account code at setAddress stage'))
address = subscriptionDetails.address
if !address
return next(new Error('no address in subscriptionDetails at setAddress stage'))
requestBody = RecurlyWrapper._addressToXml(address)
RecurlyWrapper.apiRequest({
url: "accounts/#{accountCode}/billing_info"
method: "PUT"
body: requestBody
}, (error, response, responseBody) =>
if error
logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while setting address"
return next(error)
RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) ->
if err
logger.error {err, user_id: user._id, recurly_token_id}, "error updating billing info"
return next(err)
cache.billingInfo = billingInfo
return next(null, cache)
)
createSubscription: (cache, next) ->
user = cache.user
recurly_token_id = cache.recurly_token_id
subscriptionDetails = cache.subscriptionDetails
logger.log {user_id: user._id, recurly_token_id}, "creating subscription in recurly"
requestBody = """
<subscription>
<plan_code>#{subscriptionDetails.plan_code}</plan_code>
<currency>#{subscriptionDetails.currencyCode}</currency>
<coupon_code>#{subscriptionDetails.coupon_code}</coupon_code>
<account>
<account_code>#{user._id}</account_code>
</account>
</subscription>
""" # TODO: check account details and billing
RecurlyWrapper.apiRequest({
url : "subscriptions"
method : "POST"
body : requestBody
}, (error, response, responseBody) =>
if error
logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating subscription"
return next(error)
RecurlyWrapper._parseSubscriptionXml responseBody, (err, subscription) ->
if err
logger.error {err, user_id: user._id, recurly_token_id}, "error creating subscription"
return next(err)
cache.subscription = subscription
return next(null, cache)
)
_createPaypalSubscription: (user, subscriptionDetails, recurly_token_id, callback) ->
logger.log {user_id: user._id, recurly_token_id}, "starting process of creating paypal subscription"
# We use `async.waterfall` to run each of these actions in sequence
# passing a `cache` object along the way. The cache is initialized
# with required data, and `async.apply` to pass the cache to the first function
cache = {user, recurly_token_id, subscriptionDetails}
Async.waterfall([
Async.apply(RecurlyWrapper._paypal.checkAccountExists, cache),
RecurlyWrapper._paypal.createAccount,
RecurlyWrapper._paypal.createBillingInfo,
RecurlyWrapper._paypal.setAddress,
RecurlyWrapper._paypal.createSubscription,
], (err, result) ->
if err
logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process"
return callback(err)
if !result.subscription
err = new Error('no subscription object in result')
logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process"
return callback(err)
logger.log {user_id: user._id, recurly_token_id}, "done creating paypal subscription for user"
callback(null, result.subscription)
)
_createCreditCardSubscription: (user, subscriptionDetails, recurly_token_id, callback) ->
requestBody = """
<subscription>
<plan_code>#{subscriptionDetails.plan_code}</plan_code>
@ -25,17 +215,23 @@ module.exports = RecurlyWrapper =
</account>
</subscription>
"""
@apiRequest({
RecurlyWrapper.apiRequest({
url : "subscriptions"
method : "POST"
body : requestBody
}, (error, response, responseBody) =>
return callback(error) if error?
@_parseSubscriptionXml responseBody, callback
RecurlyWrapper._parseSubscriptionXml responseBody, callback
)
createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
isPaypal = subscriptionDetails.isPaypal
logger.log {user_id: user._id, isPaypal, recurly_token_id}, "setting up subscription in recurly"
fn = if isPaypal then RecurlyWrapper._createPaypalSubscription else RecurlyWrapper._createCreditCardSubscription
fn user, subscriptionDetails, recurly_token_id, callback
apiRequest : (options, callback) ->
options.url = @apiUrl + "/" + options.url
options.url = RecurlyWrapper.apiUrl + "/" + options.url
options.headers =
"Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64")
"Accept" : "application/xml"
@ -77,11 +273,11 @@ module.exports = RecurlyWrapper =
getSubscriptions: (accountId, callback)->
@apiRequest({
RecurlyWrapper.apiRequest({
url: "accounts/#{accountId}/subscriptions"
}, (error, response, body) =>
return callback(error) if error?
@_parseXml body, callback
RecurlyWrapper._parseXml body, callback
)
@ -94,11 +290,11 @@ module.exports = RecurlyWrapper =
else
url = "subscriptions/#{subscriptionId}"
@apiRequest({
RecurlyWrapper.apiRequest({
url: url
}, (error, response, body) =>
return callback(error) if error?
@_parseSubscriptionXml body, (error, recurlySubscription) =>
RecurlyWrapper._parseSubscriptionXml body, (error, recurlySubscription) =>
return callback(error) if error?
if options.includeAccount
if recurlySubscription.account? and recurlySubscription.account.url?
@ -106,7 +302,7 @@ module.exports = RecurlyWrapper =
else
return callback "I don't understand the response from Recurly"
@getAccount accountId, (error, account) ->
RecurlyWrapper.getAccount accountId, (error, account) ->
return callback(error) if error?
recurlySubscription.account = account
callback null, recurlySubscription
@ -124,9 +320,9 @@ module.exports = RecurlyWrapper =
per_page:200
if cursor?
opts.qs.cursor = cursor
@apiRequest opts, (error, response, body) =>
RecurlyWrapper.apiRequest opts, (error, response, body) =>
return callback(error) if error?
@_parseXml body, (err, data)->
RecurlyWrapper._parseXml body, (err, data)->
if err?
logger.err err:err, "could not get accoutns"
callback(err)
@ -142,19 +338,19 @@ module.exports = RecurlyWrapper =
getAccount: (accountId, callback) ->
@apiRequest({
RecurlyWrapper.apiRequest({
url: "accounts/#{accountId}"
}, (error, response, body) =>
return callback(error) if error?
@_parseAccountXml body, callback
RecurlyWrapper._parseAccountXml body, callback
)
getBillingInfo: (accountId, callback)->
@apiRequest({
RecurlyWrapper.apiRequest({
url: "accounts/#{accountId}/billing_info"
}, (error, response, body) =>
return callback(error) if error?
@_parseXml body, callback
RecurlyWrapper._parseXml body, callback
)
@ -166,13 +362,13 @@ module.exports = RecurlyWrapper =
<timeframe>#{options.timeframe}</timeframe>
</subscription>
"""
@apiRequest({
RecurlyWrapper.apiRequest({
url : "subscriptions/#{subscriptionId}"
method : "put"
body : requestBody
}, (error, response, responseBody) =>
return callback(error) if error?
@_parseSubscriptionXml responseBody, callback
RecurlyWrapper._parseSubscriptionXml responseBody, callback
)
createFixedAmmountCoupon: (coupon_code, name, currencyCode, discount_in_cents, plan_code, callback)->
@ -191,7 +387,7 @@ module.exports = RecurlyWrapper =
</coupon>
"""
logger.log coupon_code:coupon_code, requestBody:requestBody, "creating coupon"
@apiRequest({
RecurlyWrapper.apiRequest({
url : "coupons"
method : "post"
body : requestBody
@ -203,16 +399,16 @@ module.exports = RecurlyWrapper =
lookupCoupon: (coupon_code, callback)->
@apiRequest({
RecurlyWrapper.apiRequest({
url: "coupons/#{coupon_code}"
}, (error, response, body) =>
return callback(error) if error?
@_parseXml body, callback
RecurlyWrapper._parseXml body, callback
)
cancelSubscription: (subscriptionId, callback) ->
logger.log subscriptionId:subscriptionId, "telling recurly to cancel subscription"
@apiRequest({
RecurlyWrapper.apiRequest({
url: "subscriptions/#{subscriptionId}/cancel",
method: "put"
}, (error, response, body) ->
@ -221,7 +417,7 @@ module.exports = RecurlyWrapper =
reactivateSubscription: (subscriptionId, callback) ->
logger.log subscriptionId:subscriptionId, "telling recurly to reactivating subscription"
@apiRequest({
RecurlyWrapper.apiRequest({
url: "subscriptions/#{subscriptionId}/reactivate",
method: "put"
}, (error, response, body) ->
@ -237,7 +433,7 @@ module.exports = RecurlyWrapper =
</redemption>
"""
logger.log account_code:account_code, coupon_code:coupon_code, requestBody:requestBody, "redeeming coupon for user"
@apiRequest({
RecurlyWrapper.apiRequest({
url : "coupons/#{coupon_code}/redeem"
method : "post"
body : requestBody
@ -251,7 +447,7 @@ module.exports = RecurlyWrapper =
next_renewal_date = new Date()
next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire)
logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user"
@apiRequest({
RecurlyWrapper.apiRequest({
url : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false"
method : "put"
}, (error, response, responseBody) =>
@ -261,7 +457,7 @@ module.exports = RecurlyWrapper =
)
_parseSubscriptionXml: (xml, callback) ->
@_parseXml xml, (error, data) ->
RecurlyWrapper._parseXml xml, (error, data) ->
return callback(error) if error?
if data? and data.subscription?
recurlySubscription = data.subscription
@ -270,7 +466,7 @@ module.exports = RecurlyWrapper =
callback null, recurlySubscription
_parseAccountXml: (xml, callback) ->
@_parseXml xml, (error, data) ->
RecurlyWrapper._parseXml xml, (error, data) ->
return callback(error) if error?
if data? and data.account?
account = data.account
@ -278,6 +474,15 @@ module.exports = RecurlyWrapper =
return callback "I don't understand the response from Recurly"
callback null, account
_parseBillingInfoXml: (xml, callback) ->
RecurlyWrapper._parseXml xml, (error, data) ->
return callback(error) if error?
if data? and data.billing_info?
billingInfo = data.billing_info
else
return callback "I don't understand the response from Recurly"
callback null, billingInfo
_parseXml: (xml, callback) ->
convertDataTypes = (data) ->
if data? and data["$"]?
@ -315,6 +520,3 @@ module.exports = RecurlyWrapper =
return callback(error) if error?
result = convertDataTypes(data)
callback null, result

View file

@ -82,9 +82,9 @@ module.exports = SubscriptionController =
logger.log user: user, "redirecting to plans"
res.redirect "/user/subscription/plans"
else
SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) ->
SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
return next(error) if error?
logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groups:groups, "showing subscription dashboard"
logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
plans = SubscriptionViewModelBuilder.buildViewModel()
res.render "subscriptions/dashboard",
title: "your_subscription"
@ -92,8 +92,9 @@ module.exports = SubscriptionController =
taxRate:subscription?.taxRate
plans: plans
subscription: subscription || {}
groups: groups
groupSubscriptions: groupSubscriptions
subscriptionTabActive: true
user:user
userCustomSubscriptionPage: (req, res, next)->

View file

@ -8,7 +8,7 @@ _ = require("underscore")
module.exports =
buildUsersSubscriptionViewModel: (user, callback = (error, subscription, groups) ->) ->
buildUsersSubscriptionViewModel: (user, callback = (error, subscription, memberSubscriptions) ->) ->
SubscriptionLocator.getUsersSubscription user, (err, subscription) ->
return callback(err) if err?
SubscriptionLocator.getMemberSubscriptions user, (err, memberSubscriptions = []) ->
@ -19,6 +19,7 @@ module.exports =
RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
tax = recurlySubscription?.tax_in_cents || 0
callback null, {
admin_id:subscription.admin_id
name: plan.name
nextPaymentDueAt: SubscriptionFormatters.formatDate(recurlySubscription?.current_period_ends_at)
state: recurlySubscription?.state

View file

@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
metrics = require("../../infrastructure/Metrics")
Url = require("url")
AuthenticationManager = require("../Authentication/AuthenticationManager")
UserSessionsManager = require("./UserSessionsManager")
UserUpdater = require("./UserUpdater")
settings = require "settings-sharelatex"
@ -72,6 +73,7 @@ module.exports = UserController =
if err?
logger.err err:err, user_id:user_id, "error getting user for email update"
return res.send 500
req.session.user.email = user.email
UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
if err?
logger.err err:err, "error populateGroupLicenceInvite"
@ -80,9 +82,12 @@ module.exports = UserController =
logout : (req, res)->
metrics.inc "user.logout"
logger.log user: req?.session?.user, "logging out"
sessionId = req.sessionID
user = req?.session?.user
req.session.destroy (err)->
if err
logger.err err: err, 'error destorying session'
UserSessionsManager.untrackSession(user, sessionId)
res.redirect '/login'
register : (req, res, next = (error) ->)->
@ -116,6 +121,8 @@ module.exports = UserController =
logger.log user: user, "password changed"
AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
return next(error) if error?
UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
return next(err) if err?
res.send
message:
type:'success'
@ -126,7 +133,3 @@ module.exports = UserController =
message:
type:'error'
text:'Your old password is wrong'
changeEmailAddress: (req, res)->

View file

@ -1,5 +1,6 @@
User = require("../../models/User").User
UserLocator = require("./UserLocator")
logger = require("logger-sharelatex")
module.exports =
@ -12,15 +13,22 @@ module.exports =
self.createNewUser email:email, holdingAccount:true, callback
createNewUser: (opts, callback)->
logger.log opts:opts, "creating new user"
user = new User()
user.email = opts.email
user.holdingAccount = opts.holdingAccount
username = opts.email.match(/^[^@]*/)
if username?
if opts.first_name? and opts.first_name.length != 0
user.first_name = opts.first_name
else if username?
user.first_name = username[0]
else
user.first_name = ""
if opts.last_name?
user.last_name = opts.last_name
else
user.last_name = ""
user.featureSwitches?.pdfng = true

View file

@ -6,7 +6,7 @@ logger = require("logger-sharelatex")
module.exports = UserHandler =
populateGroupLicenceInvite: (user, callback)->
populateGroupLicenceInvite: (user, callback = ->)->
logger.log user_id:user._id, "populating any potential group licence invites"
licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
if !licence?

View file

@ -26,6 +26,7 @@ module.exports =
activateAccountPage: (req, res) ->
# An 'activation' is actually just a password reset on an account that
# was set with a random password originally.
logger.log query:req.query, "activiate account page called"
if !req.query?.user_id? or !req.query?.token?
return ErrorController.notFound(req, res)
@ -34,6 +35,7 @@ module.exports =
if !user
return ErrorController.notFound(req, res)
if user.loginCount > 0
logger.log user:user, "user has already logged in so is active, sending them to /login"
# Already seen this user, so account must be activate
# This lets users keep clicking the 'activate' link in their email
# as a way to log in which, if I know our users, they will.

View file

@ -35,7 +35,8 @@ module.exports = UserRegistrationHandler =
_createNewUserIfRequired: (user, userDetails, callback)->
if !user?
UserCreator.createNewUser {holdingAccount:false, email:userDetails.email}, callback
userDetails.holdingAccount = false
UserCreator.createNewUser {holdingAccount:false, email:userDetails.email, first_name:userDetails.first_name, last_name:userDetails.last_name}, callback
else
callback null, user

View file

@ -0,0 +1,126 @@
Settings = require('settings-sharelatex')
redis = require('redis-sharelatex')
logger = require("logger-sharelatex")
Async = require('async')
_ = require('underscore')
rclient = redis.createClient(Settings.redis.web)
module.exports = UserSessionsManager =
_sessionSetKey: (user) ->
return "UserSessions:#{user._id}"
# mimic the key used by the express sessions
_sessionKey: (sessionId) ->
return "sess:#{sessionId}"
trackSession: (user, sessionId, callback=(err)-> ) ->
if !user?
logger.log {sessionId}, "no user to track, returning"
return callback(null)
if !sessionId?
logger.log {user_id: user._id}, "no sessionId to track, returning"
return callback(null)
logger.log {user_id: user._id, sessionId}, "onLogin handler"
sessionSetKey = UserSessionsManager._sessionSetKey(user)
value = UserSessionsManager._sessionKey sessionId
rclient.multi()
.sadd(sessionSetKey, value)
.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
.exec (err, response) ->
if err?
logger.err {err, user_id: user._id, sessionSetKey}, "error while adding session key to UserSessions set"
return callback(err)
UserSessionsManager._checkSessions(user, () ->)
callback()
untrackSession: (user, sessionId, callback=(err)-> ) ->
if !user?
logger.log {sessionId}, "no user to untrack, returning"
return callback(null)
if !sessionId?
logger.log {user_id: user._id}, "no sessionId to untrack, returning"
return callback(null)
logger.log {user_id: user._id, sessionId}, "onLogout handler"
sessionSetKey = UserSessionsManager._sessionSetKey(user)
value = UserSessionsManager._sessionKey sessionId
rclient.multi()
.srem(sessionSetKey, value)
.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
.exec (err, response) ->
if err?
logger.err {err, user_id: user._id, sessionSetKey}, "error while removing session key from UserSessions set"
return callback(err)
UserSessionsManager._checkSessions(user, () ->)
callback()
revokeAllUserSessions: (user, retain, callback=(err)->) ->
if !retain
retain = []
retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
if !user
logger.log {}, "no user to revoke sessions for, returning"
return callback(null)
logger.log {user_id: user._id}, "revoking all existing sessions for user"
sessionSetKey = UserSessionsManager._sessionSetKey(user)
rclient.smembers sessionSetKey, (err, sessionKeys) ->
if err?
logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
return callback(err)
keysToDelete = _.filter(sessionKeys, (k) -> k not in retain)
if keysToDelete.length == 0
logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
return callback(null)
logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
rclient.multi()
.del(keysToDelete)
.srem(sessionSetKey, keysToDelete)
.exec (err, result) ->
if err?
logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
return callback(err)
callback(null)
touch: (user, callback=(err)->) ->
if !user
logger.log {}, "no user to touch sessions for, returning"
return callback(null)
sessionSetKey = UserSessionsManager._sessionSetKey(user)
rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
if err?
logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
return callback(err)
callback(null)
_checkSessions: (user, callback=(err)->) ->
if !user
logger.log {}, "no user, returning"
return callback(null)
logger.log {user_id: user._id}, "checking sessions for user"
sessionSetKey = UserSessionsManager._sessionSetKey(user)
rclient.smembers sessionSetKey, (err, sessionKeys) ->
if err?
logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
return callback(err)
logger.log {user_id: user._id, count: sessionKeys.length}, "checking sessions for user"
Async.series(
sessionKeys.map(
(key) ->
(next) ->
rclient.get key, (err, val) ->
if err?
return next(err)
if !val?
logger.log {user_id: user._id, key}, ">> removing key from UserSessions set"
rclient.srem sessionSetKey, key, (err, result) ->
if err?
return next(err)
return next(null)
else
next()
)
, (err, results) ->
logger.log {user_id: user._id}, "done checking sessions for user"
return callback(err)
)

View file

@ -1,81 +0,0 @@
request = require("request")
settings = require("settings-sharelatex")
logger = require("logger-sharelatex")
ErrorController = require "../Errors/ErrorController"
_ = require("underscore")
AuthenticationController = require("../Authentication/AuthenticationController")
async = require("async")
other_lngs = ["es"]
module.exports = WikiController =
_checkIfLoginIsNeeded: (req, res, next)->
if settings.apis.wiki.requireLogin
AuthenticationController.requireLogin()(req, res, next)
else
next()
getPage: (req, res, next) ->
WikiController._checkIfLoginIsNeeded req, res, ->
page = req.url.replace(/^\/learn/, "").replace(/^\//, "")
if page == ""
page = "Main_Page"
logger.log page: page, "getting page from wiki"
if _.include(other_lngs, req.lng)
lngPage = "#{page}_#{req.lng}"
else
lngPage = page
jobs =
contents: (cb)->
WikiController._getPageContent "Contents", cb
pageData: (cb)->
WikiController._getPageContent lngPage, cb
async.parallel jobs, (error, results)->
return next(error) if error?
{pageData, contents} = results
if pageData.content?.length > 280
if _.include(other_lngs, req.lng)
pageData.title = pageData.title.slice(0, pageData.title.length - (req.lng.length+1) )
WikiController._renderPage(pageData, contents, res)
else
WikiController._getPageContent page, (error, pageData) ->
return next(error) if error?
WikiController._renderPage(pageData, contents, res)
_getPageContent: (page, callback = (error, data = { content: "", title: "" }) ->) ->
request {
url: "#{settings.apis.wiki.url}/learn-scripts/api.php"
qs: {
page: decodeURI(page)
action: "parse"
format: "json"
}
}, (err, response, data)->
return callback(err) if err?
try
data = JSON.parse(data)
catch err
logger.err err:err, data:data, "error parsing data from wiki"
result =
content: data?.parse?.text?['*']
title: data?.parse?.title
callback null, result
_renderPage: (page, contents, res)->
if page.title == "Main Page"
title = "Documentation"
else
title = page.title
res.render "wiki/page", {
page: page
contents: contents
title: title
}

View file

@ -7,15 +7,19 @@ querystring = require('querystring')
SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
_ = require("underscore")
Modules = require "./Modules"
Url = require "url"
fingerprints = {}
Path = require 'path'
jsPath =
if Settings.useMinifiedJs
"/minjs/"
else
"/js/"
logger.log "Generating file fingerprints..."
for path in [
"#{jsPath}libs/require.js",
@ -38,6 +42,17 @@ for path in [
else
logger.log filePath:filePath, "file does not exist for fingerprints"
getFingerprint = (path) ->
if fingerprints[path]?
return fingerprints[path]
else
logger.err "No fingerprint for file: #{path}"
return ""
logger.log "Finished generating file fingerprints"
cdnAvailable = Settings.cdn?.web?.host?
darkCdnAvailable = Settings.cdn?.web?.darkHost?
module.exports = (app, webRouter, apiRouter)->
webRouter.use (req, res, next)->
@ -45,9 +60,55 @@ module.exports = (app, webRouter, apiRouter)->
next()
webRouter.use (req, res, next)->
isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
isSmoke = req.headers?.host?.slice(0,5)?.toLowerCase() == "smoke"
isLive = !isDark and !isSmoke
if cdnAvailable and isLive
staticFilesBase = Settings.cdn?.web?.host
else if darkCdnAvailable and isDark
staticFilesBase = Settings.cdn?.web?.darkHost
else
staticFilesBase = ""
res.locals.jsPath = jsPath
res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
res.locals.buildJsPath = (jsFile, opts = {})->
path = Path.join(jsPath, jsFile)
doFingerPrint = opts.fingerprint != false
if !opts.qs?
opts.qs = {}
if !opts.qs?.fingerprint? and doFingerPrint
opts.qs.fingerprint = getFingerprint(path)
if opts.cdn != false
path = Url.resolve(staticFilesBase, path)
qs = querystring.stringify(opts.qs)
if qs? and qs.length > 0
path = path + "?" + qs
return path
res.locals.buildCssPath = (cssFile)->
path = Path.join("/stylesheets/", cssFile)
return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
res.locals.buildImgPath = (imgFile)->
path = Path.join("/img/", imgFile)
return Url.resolve(staticFilesBase, path)
next()
webRouter.use (req, res, next)->
res.locals.settings = Settings
next()
@ -113,12 +174,7 @@ module.exports = (app, webRouter, apiRouter)->
next()
webRouter.use (req, res, next)->
res.locals.fingerprint = (path) ->
if fingerprints[path]?
return fingerprints[path]
else
logger.err "No fingerprint for file: #{path}"
return ""
res.locals.fingerprint = getFingerprint
next()
webRouter.use (req, res, next)->

View file

@ -31,6 +31,7 @@ translations = require("translations-sharelatex").setup(Settings.i18n)
Modules = require "./Modules"
ErrorController = require "../Features/Errors/ErrorController"
UserSessionsManager = require "../Features/User/UserSessionsManager"
metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
@ -89,6 +90,8 @@ webRouter.use translations.setLangBasedOnDomainMiddlewear
# Measure expiry from last request, not last login
webRouter.use (req, res, next) ->
req.session.touch()
if req?.session?.user?
UserSessionsManager.touch(req.session.user, (err)->)
next()
webRouter.use ReferalConnect.use
@ -98,19 +101,19 @@ if app.get('env') == 'production'
logger.info "Production Enviroment"
app.enable('view cache')
app.use (req, res, next)->
metrics.inc "http-request"
crawlerLogger.log(req)
next()
app.use (req, res, next) ->
if !Settings.editorIsOpen
webRouter.use (req, res, next) ->
if Settings.editorIsOpen
next()
else if req.url.indexOf("/admin") == 0
next()
else
res.status(503)
res.render("general/closed", {title:"maintenance"})
else
next()
apiRouter.get "/status", (req, res)->
res.send("web sharelatex is alive")

View file

@ -58,6 +58,7 @@ UserSchema = new Schema
mendeley: Boolean # coerce the refProviders values to Booleans
zotero: Boolean
}
betaProgram: { type:Boolean, default: false}
conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: 10)

View file

@ -30,7 +30,6 @@ PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
ChatController = require("./Features/Chat/ChatController")
BlogController = require("./Features/Blog/BlogController")
WikiController = require("./Features/Wiki/WikiController")
Modules = require "./infrastructure/Modules"
RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
@ -38,6 +37,7 @@ InactiveProjectController = require("./Features/InactiveData/InactiveProjectCont
ContactRouter = require("./Features/Contacts/ContactRouter")
ReferencesController = require('./Features/References/ReferencesController')
AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
logger = require("logger-sharelatex")
_ = require("underscore")
@ -79,6 +79,7 @@ module.exports = class Router
webRouter.get '/blog/*', BlogController.getPage
webRouter.get '/user/activate', UserPagesController.activateAccountPage
AuthenticationController.addEndpointToLoginWhitelist '/user/activate'
webRouter.get '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
@ -104,7 +105,11 @@ module.exports = class Router
webRouter.post '/project/:Project_id/settings/admin', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.updateProjectAdminSettings
webRouter.post '/project/:Project_id/compile', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.compile
webRouter.get '/Project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf
webRouter.post '/project/:Project_id/compile/stop', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.stopCompile
# Used by the web download buttons, adds filename header
webRouter.get '/project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf
# Used by the pdf viewers
webRouter.get /^\/project\/([^\/]*)\/output\/(.*)$/,
((req, res, next) ->
params =
@ -118,12 +123,36 @@ module.exports = class Router
((req, res, next) ->
params =
"Project_id": req.params[0]
"build": req.params[1]
"build_id": req.params[1]
"file": req.params[2]
req.params = params
next()
), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
# direct url access to output files for user but no build, to retrieve files when build fails
webRouter.get /^\/project\/([^\/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/,
((req, res, next) ->
params =
"Project_id": req.params[0]
"user_id": req.params[1]
"file": req.params[2]
req.params = params
next()
), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
# direct url access to output files for a specific user and build (query string not required)
webRouter.get /^\/project\/([^\/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/,
((req, res, next) ->
params =
"Project_id": req.params[0]
"user_id": req.params[1]
"build_id": req.params[2]
"file": req.params[3]
req.params = params
next()
), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
webRouter.delete "/project/:Project_id/output", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.deleteAuxFiles
webRouter.get "/project/:Project_id/sync/code", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncCode
webRouter.get "/project/:Project_id/sync/pdf", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncPdf
@ -187,16 +216,13 @@ module.exports = class Router
webRouter.get "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
webRouter.get /learn(\/.*)?/, RateLimiterMiddlewear.rateLimit({
endpointName: "wiki"
params: []
maxRequests: 60
timeInterval: 60
}), WikiController.getPage
webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
webRouter.get "/beta/participate", AuthenticationController.requireLogin(), BetaProgramController.optInPage
webRouter.post "/beta/opt-in", AuthenticationController.requireLogin(), BetaProgramController.optIn
webRouter.post "/beta/opt-out", AuthenticationController.requireLogin(), BetaProgramController.optOut
#Admin Stuff
webRouter.get '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index
webRouter.get '/admin/user', AuthorizationMiddlewear.ensureUserIsSiteAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon
@ -216,9 +242,11 @@ module.exports = class Router
apiRouter.get '/status', (req,res)->
res.send("websharelatex is up")
webRouter.get '/dev/csrf', (req, res) ->
res.send res.locals.csrfToken
webRouter.get '/health_check', HealthCheckController.check
webRouter.get '/health_check/redis', HealthCheckController.checkRedis
apiRouter.get '/health_check', HealthCheckController.check
apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
sendRes = _.once (statusCode, message)->

View file

@ -9,6 +9,22 @@ block content
.page-header
h1 Admin Panel
tabset(ng-cloak)
tab(heading="System Messages")
each message in systemMessages
.alert.alert-info.row-spaced !{message.content}
hr
form(enctype='multipart/form-data', method='post', action='/admin/messages')
input(name="_csrf", type="hidden", value=csrfToken)
.form-group
label(for="content")
input.form-control(name="content", type="text", placeholder="Message...", required)
button.btn.btn-primary(type="submit") Post Message
hr
form(enctype='multipart/form-data', method='post', action='/admin/messages/clear')
input(name="_csrf", type="hidden", value=csrfToken)
button.btn.btn-danger(type="submit") Clear all messages
tab(heading="Open Sockets")
.row-spaced
ul
@ -66,19 +82,6 @@ block content
.form-group
button.btn-primary.btn(type='submit') Poll
tab(heading="System Messages")
each message in systemMessages
.alert.alert-info.row-spaced !{message.content}
hr
form(enctype='multipart/form-data', method='post', action='/admin/messages')
input(name="_csrf", type="hidden", value=csrfToken)
.form-group
label(for="content")
input.form-control(name="content", type="text", placeholder="Message...", required)
button.btn.btn-primary(type="submit") Post Message
hr
form(enctype='multipart/form-data', method='post', action='/admin/messages/clear')
input(name="_csrf", type="hidden", value=csrfToken)
button.btn.btn-danger(type="submit") Clear all messages

View file

@ -0,0 +1,47 @@
extends ../layout
block content
.content.content-alt
.container.beta-opt-in-wrapper
.row
.col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2
.card
.page-header.text-centered
h1
| #{translate("sharelatex_beta_program")}
.beta-opt-in
.container-fluid
.row
.col-md-12
p.text-centered #{translate("beta_program_benefits")}
p.text-centered
| #{translate("beta_program_badge_description")}
span.beta-feature-badge
p.text-centered
| #{translate("beta_program_current_beta_features_description")}
ul.list-unstyled.text-center
li
i.fa.fa-fw.fa-book
| &nbsp;#{translate("mendeley_integration")}
.row.text-centered
.col-md-12
if user.betaProgram
p #{translate("beta_program_already_participating")}
form(id="beta-program-opt-out", method="post", action="/beta/opt-out", novalidate)
.form-group
input(type="hidden", name="_csrf", value=csrfToken)
button.btn.btn-primary(
type="submit"
)
span #{translate("beta_program_opt_out_action")}
.form-group
a(href="/project").btn.btn-info #{translate("back_to_your_projects")}
else
form(id="beta-program-opt-in", method="post", action="/beta/opt-in", novalidate)
.form-group
input(type="hidden", name="_csrf", value=csrfToken)
button.btn.btn-primary(
type="submit"
)
span #{translate("beta_program_opt_in_action")}

View file

@ -11,7 +11,19 @@ script(type='text/ng-template', id='supportModalTemplate')
label
| #{translate("subject")}
.form-group
input.field.text.medium.span8.form-control(ng-model="form.subject", maxlength='255', tabindex='1', onkeyup='')
input.field.text.medium.span8.form-control(
ng-model="form.subject",
ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
maxlength='255',
tabindex='1',
onkeyup='')
.contact-suggestions(ng-show="suggestions.length")
p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>__kb__</a>", kb: translate("knowledge_base") })}
ul.contact-suggestion-list
li(ng-repeat="suggestion in suggestions")
a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
span(ng-bind-html="suggestion.name")
i.fa.fa-angle-right
label.desc(ng-show="'#{getUserEmail()}'.length < 1")
| #{translate("email")}
.form-group(ng-show="'#{getUserEmail()}'.length < 1")
@ -21,7 +33,7 @@ script(type='text/ng-template', id='supportModalTemplate')
.form-group
input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
label.desc
| #{translate("suggestion")}
| #{translate("contact_message_label")}
.form-group
textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
.form-group.text-center

View file

@ -3,7 +3,7 @@ html(itemscope, itemtype='http://schema.org/Product')
head
title Something went wrong
link(rel="icon", href="/favicon.ico")
link(rel='stylesheet', href='/stylesheets/style.css')
link(rel='stylesheet', href=buildCssPath('/style.css'))
link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
body
.content
@ -12,7 +12,7 @@ html(itemscope, itemtype='http://schema.org/Product')
.col-md-8.col-md-offset-2.text-center
.page-header
h2 Oh dear, something went wrong.
p: img(src="/img/lion-sad-128.png", alt="Sad Lion")
p: img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
p
| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
p

View file

@ -13,13 +13,12 @@ html(itemscope, itemtype='http://schema.org/Product')
-if (typeof(title) == "undefined")
title ShareLaTeX, the Online LaTeX Editor
title= 'ShareLaTeX, '+ translate("online_latex_editor")
-else
title= translate(title) + ' - ShareLaTeX, '+translate("online_latex_editor")
title= translate(title) + ' - ShareLaTeX, ' + translate("online_latex_editor")
link(rel="icon", href="/favicon.ico")
link(rel='stylesheet', href='/stylesheets/style.css?fingerprint='+fingerprint('/stylesheets/style.css'))
link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
link(rel='stylesheet', href=buildCssPath('/style.css'))
if settings.i18n.subdomainLang
each subdomainDetails in settings.i18n.subdomainLang
@ -30,7 +29,7 @@ html(itemscope, itemtype='http://schema.org/Product')
meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
-if (typeof(meta) == "undefined")
meta(itemprop="description", name="description", content="An online LaTeX editor that's easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.")
meta(itemprop="description", name="description", content='#{translate("site_description")}')
-else
meta(itemprop="description", name="description" , content=meta)
@ -46,15 +45,84 @@ html(itemscope, itemtype='http://schema.org/Product')
ga('send', 'pageview');
- else
script(type='text/javascript').
window.ga = function() { console.log("Sending to GA", arguments) };
window.ga = function() { console.log("would send to GA", arguments) };
// Countly Analytics
if (settings.analytics && settings.analytics.countly && settings.analytics.countly.token)
script(type="text/javascript").
var Countly = Countly || {};
Countly.q = Countly.q || [];
Countly.app_key = '#{settings.analytics.countly.token}';
Countly.url = '#{settings.analytics.countly.server}';
!{ session.user ? 'Countly.device_id = "' + session.user._id + '";' : '' }
(function() {
var cly = document.createElement('script'); cly.type = 'text/javascript';
cly.async = true;
//enter url of script here
cly.src = 'https://cdnjs.cloudflare.com/ajax/libs/countly-sdk-web/16.6.0/countly.min.js';
cly.onload = function(){Countly.init()};
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s);
})();
script(type="text/javascript")
if (session && session.user)
- var name = session.user.first_name + (session.user.last_name ? ' ' + session.user.last_name : '');
| Countly.q.push(['user_details', { email: '#{session.user.email}', name: '#{name}' }]);
if (justRegistered)
| Countly.q.push(['add_event',{ key: 'user-registered' }]);
if (justLoggedIn)
| Countly.q.push(['add_event',{ key: 'user-logged-in' }]);
if (user && user.features)
- featureFlagSet = false;
if user.features.hasOwnProperty('collaborators')
| Countly.q.push([ 'userData.set', 'features-collaborators', #{ user.features.collaborators } ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('compileGroup')
| Countly.q.push([ 'userData.set', 'features-compileGroup', '#{ user.features.compileGroup }' ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('compileTimeout')
| Countly.q.push([ 'userData.set', 'features-compileTimeout', #{ user.features.compileTimeout } ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('dropbox')
| Countly.q.push([ 'userData.set', 'features-dropbox', #{ user.features.dropbox } ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('github')
| Countly.q.push([ 'userData.set', 'features-github', #{ user.features.github } ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('references')
| Countly.q.push([ 'userData.set', 'features-references', #{ user.features.references } ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('templates')
| Countly.q.push([ 'userData.set', 'features-templates', #{ user.features.templates } ]);
- featureFlagSet = true;
if user.features.hasOwnProperty('versioning')
| Countly.q.push([ 'userData.set', 'features-versioning', #{ user.features.versioning } ]);
- featureFlagSet = true;
if featureFlagSet
| Countly.q.push(['userData.save'])
// End countly Analytics
script(type="text/javascript").
window.csrfToken = "#{csrfToken}";
block scripts
script(src="#{jsPath}libs/jquery-1.11.1.min.js")
script(src="#{jsPath}libs/angular-1.3.15.min.js")
include sentry
script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
script.
window.sharelatex = {
siteUrl: '#{settings.siteUrl}',
@ -123,12 +191,14 @@ html(itemscope, itemtype='http://schema.org/Product')
}
};
script(
data-main=jsPath+'main.js',
baseurl=jsPath,
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
data-main=buildJsPath('main.js', {fingerprint:false}),
baseurl=fullJsPath,
src=buildJsPath('libs/require.js')
)
include contact-us-modal
include sentry

View file

@ -15,7 +15,7 @@ footer.site-footer
aria-expanded="false",
tooltip="#{translate('language')}"
)
img(src="/img/flags/24/#{currentLngCode}.png")
figure(class="sprite-icon sprite-icon-lang sprite-icon-#{currentLngCode}")
ul.dropdown-menu(role="menu")
li.dropdown-header #{translate("language")}
@ -23,9 +23,9 @@ footer.site-footer
if !subdomainDetails.hide
li.lngOption
a.menu-indent(href=subdomainDetails.url+currentUrl)
img(src="/img/flags/24/#{subdomainDetails.lngCode}.png")
figure(class="sprite-icon sprite-icon-lang sprite-icon-#{subdomainDetails.lngCode}")
| #{translate(subdomainDetails.lngCode)}
//- img(src="/img/flags/24/.png")
each item in nav.left_footer
li
if item.url
@ -41,4 +41,3 @@ footer.site-footer
a(href=item.url, class=item.class) !{item.text}
else
| !{item.text}

View file

@ -3,7 +3,9 @@ nav.navbar.navbar-default
.navbar-header
button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}")
i.fa.fa-bars
if nav.title
if settings.nav.custom_logo
a(href='/', style='background-image:url("#{settings.nav.custom_logo}")').navbar-brand
else if (nav.title)
a(href='/').navbar-title #{nav.title}
else
a(href='/').navbar-brand
@ -17,6 +19,8 @@ nav.navbar.navbar-default
| Admin
b.caret
ul.dropdown-menu
li
a(href="/admin") Manage Site
li
a(href="/admin/user") Manage Users

View file

@ -97,9 +97,10 @@ block content
window.csrfToken = "!{csrfToken}";
window.anonymous = #{anonymous};
window.maxDocLength = #{maxDocLength};
window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
window.requirejs = {
"paths" : {
"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML",
"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {qs:{config:'TeX-AMS_HTML', fingerprint:false}})}",
"moment": "libs/moment-2.7.0",
"libs/pdf": "libs/pdfjs-1.3.91/pdf"
},
@ -128,15 +129,18 @@ block content
- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
- var fingerprintedPath = fingerprint(jsPath+pdfPath)
- var pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath
- var pdfJsWorkerPath = buildJsPath(pdfPath, {cdn:false,qs:{fingerprint:fingerprintedPath}}) // don't use worker for cdn
script(type='text/javascript').
window.pdfJsWorkerPath = "#{pdfJsWorkerPath}";
script(
data-main=jsPath+"ide.js",
baseurl=jsPath,
data-ace-base=jsPath+'ace',
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
data-main=buildJsPath("ide.js", {fingerprint:false}),
baseurl=fullJsPath,
data-ace-base=buildJsPath('ace', {fingerprint:false}),
src=buildJsPath('libs/require.js')
)

View file

@ -35,7 +35,7 @@ div.full-size(
resize-on="layout:main:resize,layout:pdf:resize",
annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
read-only="!permissions.write",
on-ctrl-enter="recompile"
on-ctrl-enter="recompileViaKey"
)
.ui-layout-east

View file

@ -15,7 +15,7 @@ aside#left-menu.full-size(
| #{translate("source")}
li
a(
ng-href="{{pdf.url}}"
ng-href="{{pdf.downloadUrl || pdf.url}}"
target="_blank"
ng-if="pdf.url"
)

View file

@ -1,6 +1,13 @@
div.full-size.pdf(ng-controller="PdfController")
.toolbar.toolbar-tall
.btn-group(dropdown)
.btn-group(
dropdown,
tooltip-html="'#{translate('recompile_pdf')} <span class=\"keyboard-shortcut\">({{modifierKey}} + Enter)</span>'"
tooltip-class="keyboard-tooltip"
tooltip-popup-delay="500"
tooltip-append-to-body="true"
tooltip-placement="bottom"
)
a.btn.btn-info(
href,
ng-disabled="pdf.compiling",
@ -29,6 +36,14 @@ div.full-size.pdf(ng-controller="PdfController")
i.fa.fa-fw(ng-class="{'fa-check': draft}")
| &nbsp;#{translate("fast")}&nbsp;
span.subdued [draft]
a(
href
ng-click="stop()"
ng-show="pdf.compiling",
tooltip="#{translate('stop_compile')}"
tooltip-placement="bottom"
)
i.fa.fa-stop()
a.log-btn(
href
ng-click="toggleLogs()"
@ -46,7 +61,7 @@ div.full-size.pdf(ng-controller="PdfController")
) {{ pdf.logEntries.errors.length + pdf.logEntries.warnings.length }}
a(
ng-href="{{pdf.url}}"
ng-href="{{pdf.downloadUrl || pdf.url}}"
target="_blank"
ng-if="pdf.url"
tooltip="#{translate('download_pdf')}"
@ -93,15 +108,98 @@ div.full-size.pdf(ng-controller="PdfController")
'alert-info': entry.level == 'typesetting'\
}"
ng-click="openInEditor(entry)"
ng-init="feedbackSent = false; showNegFeedbackUI = false; negFeedbackReason = ''; negFeedbackReasonFreeText = ''"
)
span.line-no
i.fa.fa-link(aria-hidden="true")
| &nbsp;
span(ng-show="entry.file") {{ entry.file }}
span(ng-show="entry.line") , line {{ entry.line }}
p.entry-message(ng-show="entry.message") {{ entry.message }}
p.entry-content(ng-show="entry.content") {{ entry.content }}
.card.card-hint(
ng-if="entry.humanReadableHint"
stop-propagation="click"
)
figure.card-hint-icon-container
i.fa.fa-lightbulb-o(aria-hidden="true")
p.card-hint-text(
ng-show="entry.humanReadableHint",
ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
.card-hint-actions.clearfix
.card-hint-ext-link(ng-if="wikiEnabled")
a(
ng-href="{{ entry.extraInfoURL }}",
ng-click="trackLogHintsLearnMore()"
target="_blank"
)
i.fa.fa-external-link
|&nbsp;#{translate("log_hint_extra_info")}
.card-hint-feedback(
ng-hide="feedbackSent || showNegFeedbackUI"
ng-class="entry.ruleId"
)
label.card-hint-feedback-label #{translate("log_hint_feedback_label")}
a.card-hint-feedback-positive(
ng-click="trackLogHintsPositiveFeedback(entry.ruleId); feedbackSent = true;"
href
) #{translate("answer_yes")}
span &nbsp;/&nbsp;
a.card-hint-feedback-negative(
ng-click="trackLogHintsNegativeFeedback(entry.ruleId); showNegFeedbackUI = true;"
href
) #{translate("answer_no")}
.card-hint-extra-feedback(ng-hide="!showNegFeedbackUI || feedbackSent")
p.card-hint-extra-feedback-label #{translate("log_hint_ask_extra_feedback")}
.radio: label
input(
type="radio"
name="{{ 'neg-feedback-reason-' + $index }}"
ng-model="negFeedbackReason"
value="{{ logHintsNegFeedbackValues.DIDNT_UNDERSTAND }}"
)
| #{translate("log_hint_extra_feedback_didnt_understand")}
.radio: label
input(
type="radio"
name="{{ 'neg-feedback-reason-' + $index }}"
ng-model="negFeedbackReason"
value="{{ logHintsNegFeedbackValues.NOT_APPLICABLE }}"
)
| #{translate("log_hint_extra_feedback_not_applicable")}
.radio: label
input(
type="radio"
name="{{ 'neg-feedback-reason-' + $index }}"
ng-model="negFeedbackReason"
value="{{ logHintsNegFeedbackValues.INCORRECT }}"
)
| #{translate("log_hint_extra_feedback_incorrect")}
.radio: label
input(
type="radio"
name="{{ 'neg-feedback-reason-' + $index }}"
ng-model="negFeedbackReason"
value="{{ logHintsNegFeedbackValues.OTHER }}"
)
| #{translate("log_hint_extra_feedback_other")}
textarea.form-control(
ng-show="negFeedbackReason === logHintsNegFeedbackValues.OTHER"
ng-model="negFeedbackReasonFreeText"
rows="2"
)
.clearfix
button.btn.btn-default.btn-sm.pull-right(
ng-disabled="!negFeedbackReason"
ng-click="trackLogHintsNegFeedbackDetails(entry.ruleId, negFeedbackReason, negFeedbackReasonFreeText); feedbackSent = true;"
) #{translate("log_hint_extra_feedback_submit")}
.card-hint-feedback(ng-show="feedbackSent")
label.card-hint-feedback-label #{translate("log_hint_feedback_gratitude")}
p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
p
.pull-right
.files-dropdown-container
a.btn.btn-default.btn-sm(
href,
tooltip="#{translate('clear_cached_files')}",
@ -111,7 +209,10 @@ div.full-size.pdf(ng-controller="PdfController")
)
i.fa.fa-trash-o
| &nbsp;
div.dropdown(style="display: inline-block;", dropdown)
div.files-dropdown(
ng-class="shouldDropUp ? 'dropup' : 'dropdown'"
dropdown
)
a.btn.btn-default.btn-sm(
href
dropdown-toggle
@ -154,6 +255,27 @@ div.full-size.pdf(ng-controller="PdfController")
ng-if="settings.pdfViewer == 'native'"
)
.pdf-validation-problems(ng-switch-when="validation-problems")
.alert.alert-danger(ng-show="pdf.validation.duplicatePaths")
strong #{translate("latex_error")}
span #{translate("duplicate_paths_found")}
.alert.alert-danger(ng-show="pdf.validation.sizeCheck")
strong #{translate("project_too_large")}
div #{translate("project_too_large_please_reduce")}
div
li(ng-repeat="entry in pdf.validation.sizeCheck.resources") {{ '/'+entry['path'] }} - {{entry['kbSize']}}kb
.alert.alert-danger(ng-show="pdf.validation.conflictedPaths")
div
strong #{translate("conflicting_paths_found")}
div #{translate("following_paths_conflict")}
div
li(ng-repeat="entry in pdf.validation.conflictedPaths") {{ '/'+entry['path'] }}
.pdf-errors(ng-switch-when="errors")
.alert.alert-danger(ng-show="pdf.error")
@ -172,6 +294,10 @@ div.full-size.pdf(ng-controller="PdfController")
strong #{translate("server_error")}
span #{translate("too_recently_compiled")}
.alert.alert-danger(ng-show="pdf.compileTerminated")
strong #{translate("terminated")}.
span #{translate("compile_terminated_by_user")}
.alert.alert-danger(ng-show="pdf.timedout")
p
strong #{translate("timedout")}.

View file

@ -49,7 +49,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
.form-group
tags-input(
template="shareTagTemplate"
placeholder="joe@example.com, sue@example.com, ..."
placeholder="#{settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...'}"
ng-model="inputs.contacts"
focus-on="open"
display-property="display"

View file

@ -94,18 +94,7 @@
- if (showUserDetailsArea)
span(ng-controller="LeftHandMenuPromoController", ng-cloak)
.row-spaced(ng-if="showDatajoy")
hr
.card.card-thin
p.text-center.small
| <strong>Python</strong> or <strong>R</strong> user?
p.text-center.small
a(href="https://www.getdatajoy.com/", target="_blank").btn.btn-info.btn-small Try DataJoy
p.text-center.small(style="font-size: 0.8em")
a(href="https://www.getdatajoy.com/", target="_blank") DataJoy
| is a new online Python and R editor from ShareLaTeX.
.row-spaced#userProfileInformation(ng-if="hasProjects && !showDatajoy")
.row-spaced#userProfileInformation(ng-if="hasProjects")
div(ng-controller="UserProfileController")
hr(ng-show="percentComplete < 100")
.text-centered.user-profile(ng-show="percentComplete < 100")
@ -120,7 +109,7 @@
) #{translate("complete")}
.row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
span(sixpack-default).text-centered
hr
p.small #{translate("on_free_sl")}
@ -146,7 +135,7 @@
p
span Get Dropbox Sync
p
img(src="/img/dropbox/simple_logo.png")
img(src=buildImgPath("dropbox/simple_logo.png"))
p
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
p.small.text-centered
@ -159,13 +148,13 @@
p
span Get Github Sync
p
img(src="/img/github/octocat.jpg")
img(src=buildImgPath("github/octocat.jpg"))
p
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
p.small.text-centered
| #{translate("or_unlock_features_bonus")}
a(href="/user/bonus") #{translate("sharing_sl")} .
script.
window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription}
window.userHasNoSubscription = #{settings.enableSubscriptions && !hasSubscription}

View file

@ -2,7 +2,7 @@
- if (sentrySrc.match(/^([a-z]+:)?\/\//i))
script(src="#{sentrySrc}")
- else
script(src="#{jsPath}libs/#{sentrySrc}")
script(src=buildJsPath("libs/#{sentrySrc}", {fingerprint:false}))
- if (typeof(sentrySrc) != "undefined")
script(type="text/javascript").
if (typeof(Raven) != "undefined" && Raven.config) {

View file

@ -43,12 +43,8 @@ block content
.card(ng-if="view == 'overview'")
.page-header
h1 #{translate("your_subscription")}
-if (groups.length != 0)
each groupSubscription in groups
p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
span
button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
- else if (subscription)
- if (subscription && user._id+'' == subscription.admin_id+'')
case subscription.state
when "free-trial"
p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
@ -72,15 +68,9 @@ block content
p !{translate("your_subscription_has_expired")}
a(href="/user/subscription/plans") !{translate("create_new_subscription")}
default
-if(groups.length == 0)
-if(groupSubscriptions.length == 0)
p !{translate("problem_with_subscription_contact_us")}
-if(subscription.groupPlan)
a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
div(ng-show="changePlan", ng-cloak)#changePlanSection
h2.col-md-7 !{translate("change_plan")}
span.dropdown.col-md-1.changePlanButton(ng-controller="CurrenyDropdownController", style="display:none", dropdown)
@ -105,6 +95,20 @@ block content
mixin printPlans(plans.individualMonthlyPlans)
mixin printPlans(plans.individualAnnualPlans)
each groupSubscription in groupSubscriptions
- if (user._id+'' != groupSubscription.admin_id._id+'')
div
p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
span
button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
-if(subscription.groupPlan && user._id+'' == subscription.admin_id+'')
div
a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
.card(ng-if="view == 'cancelation'")
.page-header
h1 #{translate("Cancel Subscription")}

View file

@ -3,7 +3,7 @@ extends ../layout
block content
- locals.supressDefaultJs = true
script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
script(src=jsPath+'libs/recurly.min.js')
script(src=buildJsPath('libs/recurly.min.js'))
.content.content-alt
.container

View file

@ -30,10 +30,10 @@ block content
| Henry and James
.portraits
span.img-circle
img(src="/img/about/henry_oswald.jpg")
img(src=buildImgPath("about/henry_oswald.jpg"))
| &nbsp;
span.img-circle
img(src="/img/about/james_allen.jpg")
img(src=buildImgPath("about/james_allen.jpg"))
p
a.btn.btn-primary(href="/project") &lt; #{translate("back_to_your_projects")}

View file

@ -2,7 +2,7 @@
span(ng-controller="TranslationsPopupController", ng-cloak)
.translations-message(ng-hide="hidei18nNotification")
a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"<strong>" + translate(recomendSubdomain.lngCode) + "</strong>"})}
img(src="/img/flags/24/#{recomendSubdomain.lngCode}.png")
img(src=buildImgPath("flags/24/#{recomendSubdomain.lngCode}.png"))
button(ng-click="dismiss()").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}

View file

@ -0,0 +1,19 @@
extends ../layout
block content
.masthead
.container
.row
.col-md-12
h1 !{viewData.title}
.row.row-spaced
.pattern-container
.content
.container
.row
.col-md-10.col-md-offset-1
.card
.row
| !{viewData.html}

View file

@ -99,6 +99,17 @@ block content
| !{moduleIncludes("userSettings", locals)}
if (user.betaProgram)
hr
h3
| #{translate("sharelatex_beta_program")}
p.small
| #{translate("beta_program_already_participating")}
div
a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
hr
if !externalAuthenticationSystemUsed()

View file

@ -1,68 +0,0 @@
extends ../layout
block content
.content.content-alt(ng-cloak)
.container.wiki
.row.template-page-header
.col-md-8(ng-cloak)
.row
.col-xs-3.contents(ng-non-bindable)
| !{contents.content}
.col-xs-9.page
- if(typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined")
span(ng-controller="SearchWikiController")
.row
form.project-search.form-horizontal.col-md-9(role="form")
.form-group.has-feedback.has-feedback-left.col-md-12
input.form-control.col-md-12(type='text', ng-model='searchQueryText', ng-keyup='search()', placeholder="Search help library....")
i.fa.fa-search.form-control-feedback-left
i.fa.fa-times.form-control-feedback(
ng-click="clearSearchText()",
style="cursor: pointer;",
ng-show="searchQueryText.length > 0"
)
.col-md-3.text-right
a.btn.btn-primary(ng-click="showMissingTemplateModal()") #{translate("suggest_new_doc")}
.row
.col-md-12(ng-cloak)
a(ng-href='{{hit.url}}',ng-repeat='hit in hits').search-result.card.card-thin
span(ng-bind-html='hit.name')
div.search-result-content(ng-show="hit.content != ''", ng-bind-html='hit.content')
.card.row-spaced(ng-non-bindable)
.page-header
h1 #{title}
| !{page.content}
script(type="text/ng-template", id="missingWikiPageModal")
.modal-header
button.close(
type="button"
data-dismiss="modal"
ng-click="close()"
) &times;
h3 #{translate("suggest_new_doc")}
.modal-body.contact-us-modal
span(ng-show="sent == false")
label.desc
| #{translate("email")} (#{translate("optional")})
.form-group
input.field.text.medium.span8.form-control(ng-model="form.email", ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
label.desc
| #{translate("suggestion")}
.form-group
textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', maxlength='255', tabindex='4', onkeyup='')
span(ng-show="sent")
p #{translate("request_sent_thank_you")}
.modal-footer
button.btn.btn-default(ng-click="close()")
span #{translate("dismiss")}
button.btn-success.btn(type='submit', ng-disabled="sending", ng-click="contactUs()") #{translate("contact_us")}

View file

@ -15,7 +15,7 @@ httpAuthUsers[httpAuthUser] = httpAuthPass
sessionSecret = "secret-please-change"
module.exports =
module.exports = settings =
# File storage
# ------------
#
@ -104,16 +104,21 @@ module.exports =
url: "http://localhost:3036"
sixpack:
url: ""
references:
url: "http://localhost:3040"
notifications:
url: "http://localhost:3042"
# references:
# url: "http://localhost:3040"
# notifications:
# url: "http://localhost:3042"
templates:
user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
showSocialButtons: false
showComments: false
# cdn:
# web:
# host:"http://cdn.sharelatex.dev:3000"
# darkHost:"http://cdn.sharelatex.dev:3000"
# Where your instance of ShareLaTeX can be found publically. Used in emails
# that are sent out, generated links, etc.
siteUrl : siteUrl = 'http://localhost:3000'
@ -137,6 +142,7 @@ module.exports =
# --------
security:
sessionSecret: sessionSecret
bcryptRounds: 12 # number of rounds used to hash user passwords (raised to power 2)
httpAuthUsers: httpAuthUsers
@ -262,6 +268,10 @@ module.exports =
# public projects, /learn, /templates, about pages, etc.
allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
# Use a single compile directory for all users in a project
# (otherwise each user has their own directory)
# disablePerUserCompiles: true
# Maximum size of text documents in the real-time editing system.
max_doc_length: 2 * 1024 * 1024 # 2mb
@ -335,6 +345,8 @@ module.exports =
}]
}]
customisation: {}
# templates: [{
# name : "cv_or_resume",
# url : "/templates/cv"

3853
services/web/npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@
"body-parser": "^1.13.1",
"bufferedstream": "1.6.0",
"connect-redis": "2.3.0",
"contentful": "^3.3.14",
"cookie": "^0.2.3",
"cookie-parser": "1.3.5",
"csurf": "^1.8.3",
@ -30,8 +31,9 @@
"jade": "~1.3.1",
"ldapjs": "^0.7.1",
"logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
"lodash": "^4.13.1",
"lynx": "0.1.1",
"marked": "^0.3.3",
"marked": "^0.3.5",
"method-override": "^2.3.3",
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
"mimelib": "0.2.14",
@ -54,32 +56,35 @@
"settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
"sixpack-client": "^1.0.0",
"temp": "^0.8.3",
"translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
"underscore": "1.6.0",
"underscore.string": "^3.0.2",
"v8-profiler": "^5.2.3",
"xml2js": "0.2.0"
},
"devDependencies": {
"bunyan": "0.22.1",
"translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
"chai": "",
"chai-spies": "",
"grunt": "0.4.1",
"grunt-available-tasks": "0.4.1",
"grunt-bunyan": "0.5.0",
"grunt-concurrent": "0.4.3",
"grunt-contrib-clean": "0.5.0",
"grunt-contrib-coffee": "0.10.0",
"grunt-contrib-less": "0.9.0",
"grunt-contrib-requirejs": "0.4.1",
"grunt-execute": "0.1.5",
"grunt-contrib-watch": "^1.0.0",
"grunt-env": "0.4.4",
"clean-css": "^3.4.18",
"grunt-exec": "^0.4.7",
"grunt-execute": "^0.2.2",
"grunt-file-append": "0.0.6",
"grunt-git-rev-parse": "^0.1.4",
"grunt-mocha-test": "0.9.0",
"grunt-newer": "^1.2.0",
"grunt-parallel": "^0.5.1",
"grunt-sed": "^0.1.1",
"sandboxed-module": "0.2.0",
"sinon": "",
"timekeeper": "",
"grunt-env": "0.4.4"
"timekeeper": ""
}
}

View file

@ -42,7 +42,7 @@ define [
ReferencesManager
) ->
App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
App.controller "IdeController", ($scope, $timeout, ide, localStorage, event_tracking) ->
# Don't freak out if we're already in an apply callback
$scope.$originalApply = $scope.$apply
$scope.$apply = (fn = () ->) ->
@ -69,6 +69,17 @@ define [
$scope.chat = {}
# Tracking code.
$scope.$watch "ui.view", (newView, oldView) ->
if newView? and newView != "editor" and newView != "pdf"
event_tracking.sendCountlyOnce "ide-open-view-#{ newView }-once"
$scope.$watch "ui.chatOpen", (isOpen) ->
event_tracking.sendCountlyOnce "ide-open-chat-once" if isOpen
$scope.$watch "ui.leftMenuShown", (isOpen) ->
event_tracking.sendCountlyOnce "ide-open-left-menu-once" if isOpen
# End of tracking code.
window._ide = ide

View file

@ -74,15 +74,17 @@ define [
editor.commands.removeCommand "foldall"
# For European keyboards, the / is above 7 so needs Shift pressing.
# This comes through as Ctrl-Shift-/ which is mapped to toggleBlockComment.
# This comes through as Command-Shift-/ on OS X, which is mapped to
# toggleBlockComment.
# This doesn't do anything for LaTeX, so remap this to togglecomment to
# work for European keyboards as normal.
# On Windows, the key combo comes as Ctrl-Shift-7.
editor.commands.removeCommand "toggleBlockComment"
editor.commands.removeCommand "togglecomment"
editor.commands.addCommand {
name: "togglecomment",
bindKey: { win: "Ctrl-/|Ctrl-Shift-/", mac: "Command-/|Command-Shift-/" },
bindKey: { win: "Ctrl-/|Ctrl-Shift-7", mac: "Command-/|Command-Shift-/" },
exec: (editor) -> editor.toggleCommentLines(),
multiSelectAction: "forEachLine",
scrollIntoView: "selectionPart"

View file

@ -134,6 +134,8 @@ define [
editor.completer.showPopup(editor)
editor.completer.cancelContextMenu()
$(editor.completer.popup?.container).css({'font-size': @$scope.fontSize + 'px'})
if editor.completer?.completions?.filtered?.length == 0
editor.completer.detach()
bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
}

View file

@ -2,8 +2,10 @@ define [
"base"
"ace/ace"
], (App) ->
App.controller "HotkeysController", ($scope, $modal) ->
App.controller "HotkeysController", ($scope, $modal, event_tracking) ->
$scope.openHotkeysModal = ->
event_tracking.sendCountly "ide-open-hotkeys-modal"
$modal.open {
templateUrl: "hotkeysModalTemplate"
controller: "HotkeysModalController"

View file

@ -0,0 +1,20 @@
define [
"libs/latex-log-parser"
"ide/human-readable-logs/HumanReadableLogsRules"
], (LogParser, ruleset) ->
parse : (rawLog, options) ->
parsedLogEntries = LogParser.parse(rawLog, options)
_getRule = (logMessage) ->
return rule for rule in ruleset when rule.regexToMatch.test logMessage
for entry in parsedLogEntries.all
ruleDetails = _getRule entry.message
if (ruleDetails?)
entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.regexToMatch?
entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?
return parsedLogEntries

View file

@ -0,0 +1,91 @@
define -> [
regexToMatch: /Misplaced alignment tab character \&/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/Misplaced_alignment_tab_character_%26"
humanReadableHint: """
You have placed an alignment tab character '&' in the wrong place. If you want to align something, you must write it inside an align environment such as \\begin{align} \u2026 \\end{align}, \\begin{tabular} \u2026 \\end{tabular}, etc. If you want to write an ampersand '&' in text, you must write \\& instead.
"""
,
regexToMatch: /Extra alignment tab has been changed to \\cr/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr"
humanReadableHint: """
You have written too many alignment tabs in a table, causing one of them to be turned into a line break. Make sure you have specified the correct number of columns in your <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Tables\">table</a>.
"""
,
regexToMatch: /Display math should end with \$\$/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$"
humanReadableHint: """
You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ \u2026 $$. Check that the number of $s match around each math expression.
"""
,
regexToMatch: /Missing [{$] inserted./
extraInfoURL: "https://www.sharelatex.com/learn/Errors/Missing_$_inserted"
humanReadableHint: """
Check that your $'s match around math expressions. If they do, then you've probably used a symbol in normal text that needs to be in math mode. Symbols such as subscripts ( _ ), integrals ( \\int ), Greek letters ( \\alpha, \\beta, \\delta ), and modifiers (\\vec{x}, \\tilde{x} ) must be written in math mode. See the full list <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/Missing_$_inserted \">here</a>.If you intended to use mathematics mode, then use $ \u2026 $ for 'inline math mode', $$ \u2026 $$ for 'display math mode' or alternatively \begin{math} \u2026 \end{math}.
"""
,
regexToMatch: /(undefined )?[rR]eference(s)?.+(undefined)?/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references"
humanReadableHint: """
You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.
"""
,
regexToMatch: /Citation .+ on page .+ undefined on input line .+/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX"
humanReadableHint: """
You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.
"""
,
regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels"
humanReadableHint: """
You have used the same label more than once. Check that each \\label{...} labels only one item.
"""
,
regexToMatch: /`!?h' float specifier changed to `!?ht'/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27"
humanReadableHint: """
The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">float package</a>.
"""
,
regexToMatch: /No positions in optional float specifier/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
humanReadableHint: """
You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
"""
,
regexToMatch: /Undefined control sequence/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence"
humanReadableHint: """
The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.
"""
,
regexToMatch: /File .+ not found/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX"
humanReadableHint: """
The compiler cannot find the file you want to include. Make sure that you have <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Including_images_in_ShareLaTeX\">uploaded the file</a> and <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.\">specified the file location correctly</a>.
"""
,
regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX"
humanReadableHint: """
The compiler does not recognise the file type of one of your images. Make sure you are using a <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.\">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.
"""
,
regexToMatch: /LaTeX Error: Unknown float option `H'/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27"
humanReadableHint: """
The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.
"""
,
regexToMatch: /LaTeX Error: Unknown float option `q'/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27"
humanReadableHint: """
You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
"""
,
regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/
extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode"
humanReadableHint: """
You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
"""
]

View file

@ -1,15 +1,67 @@
define [
"base"
"libs/latex-log-parser"
"ace/ace"
"ide/human-readable-logs/HumanReadableLogs"
"libs/bib-log-parser"
], (App, LogParser, BibLogParser) ->
App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) ->
"services/log-hints-feedback"
], (App, Ace, HumanReadableLogs, BibLogParser) ->
App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, logHintsFeedback, localStorage) ->
# enable per-user containers by default
perUserCompile = true
autoCompile = true
# pdf.view = uncompiled | pdf | errors
$scope.pdf.view = if $scope?.pdf?.url then 'pdf' else 'uncompiled'
$scope.shouldShowLogs = false
$scope.wikiEnabled = window.wikiEnabled;
# view logic to check whether the files dropdown should "drop up" or "drop down"
$scope.shouldDropUp = false
logsContainerEl = document.querySelector ".pdf-logs"
filesDropdownEl = logsContainerEl?.querySelector ".files-dropdown"
# get the top coordinate of the files dropdown as a ratio (to the logs container height)
# logs container supports scrollable content, so it's possible that ratio > 1.
getFilesDropdownTopCoordAsRatio = () ->
filesDropdownEl?.getBoundingClientRect().top / logsContainerEl?.getBoundingClientRect().height
$scope.$watch "shouldShowLogs", (shouldShow) ->
if shouldShow
$scope.$applyAsync () ->
$scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
# log hints tracking
$scope.logHintsNegFeedbackValues = logHintsFeedback.feedbackOpts
$scope.trackLogHintsLearnMore = () ->
event_tracking.sendCountly "logs-hints-learn-more"
trackLogHintsFeedback = (isPositive, hintId) ->
event_tracking.send "log-hints", (if isPositive then "feedback-positive" else "feedback-negative"), hintId
event_tracking.sendCountly (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
$scope.trackLogHintsNegFeedbackDetails = (hintId, feedbackOpt, feedbackOtherVal) ->
logHintsFeedback.submitFeedback hintId, feedbackOpt, feedbackOtherVal
$scope.trackLogHintsPositiveFeedback = (hintId) -> trackLogHintsFeedback true, hintId
$scope.trackLogHintsNegativeFeedback = (hintId) -> trackLogHintsFeedback false, hintId
if ace.require("ace/lib/useragent").isMac
$scope.modifierKey = "Cmd"
else
$scope.modifierKey = "Ctrl"
# utility for making a query string from a hash, could use jquery $.param
createQueryString = (args) ->
qs_args = ("#{k}=#{v}" for k, v of args)
if qs_args.length then "?" + qs_args.join("&") else ""
$scope.stripHTMLFromString = (htmlStr) ->
tmp = document.createElement("DIV")
tmp.innerHTML = htmlStr
return tmp.textContent || tmp.innerText || ""
$scope.$on "project:joined", () ->
return if !autoCompile
@ -28,13 +80,14 @@ define [
sendCompileRequest = (options = {}) ->
url = "/project/#{$scope.project_id}/compile"
params = {}
if options.isAutoCompile
url += "?auto_compile=true"
params["auto_compile"]=true
return $http.post url, {
rootDoc_id: options.rootDocOverride_id or null
draft: $scope.draft
_csrf: window.csrfToken
}
}, {params: params}
parseCompileResponse = (response) ->
@ -42,20 +95,36 @@ define [
$scope.pdf.error = false
$scope.pdf.timedout = false
$scope.pdf.failure = false
$scope.pdf.projectTooLarge = false
$scope.pdf.url = null
$scope.pdf.clsiMaintenance = false
$scope.pdf.tooRecentlyCompiled = false
$scope.pdf.renderingError = false
$scope.pdf.projectTooLarge = false
$scope.pdf.compileTerminated = false
# make a cache to look up files by name
fileByPath = {}
for file in response.outputFiles
if response?.outputFiles?
for file in response?.outputFiles
fileByPath[file.path] = file
# prepare query string
qs = {}
# add a query string parameter for the compile group
if response.compileGroup?
ide.compileGroup = qs.compileGroup = response.compileGroup
# add a query string parameter for the clsi server id
if response.clsiServerId?
ide.clsiServerId = qs.clsiserverid = response.clsiServerId
if response.status == "timedout"
$scope.pdf.view = 'errors'
$scope.pdf.timedout = true
fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
else if response.status == "terminated"
$scope.pdf.view = 'errors'
$scope.pdf.compileTerminated = true
fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
else if response.status == "autocompile-backoff"
$scope.pdf.view = 'uncompiled'
else if response.status == "project-too-large"
@ -72,12 +141,13 @@ define [
else if response.status == "too-recently-compiled"
$scope.pdf.view = 'errors'
$scope.pdf.tooRecentlyCompiled = true
else if response.status == "validation-problems"
$scope.pdf.view = "validation-problems"
$scope.pdf.validation = response.validationProblems
else if response.status == "success"
$scope.pdf.view = 'pdf'
$scope.shouldShowLogs = false
# prepare query string
qs = {}
# define the base url. if the pdf file has a build number, pass it to the clsi in the url
if fileByPath['output.pdf']?.url?
$scope.pdf.url = fileByPath['output.pdf'].url
@ -89,17 +159,11 @@ define [
# check if we need to bust cache (build id is unique so don't need it in that case)
if not fileByPath['output.pdf']?.build?
qs.cache_bust = "#{Date.now()}"
# add a query string parameter for the compile group
if response.compileGroup?
$scope.pdf.compileGroup = response.compileGroup
qs.compileGroup = "#{$scope.pdf.compileGroup}"
if response.clsiServerId?
qs.clsiserverid = response.clsiServerId
ide.clsiServerId = response.clsiServerId
# convert the qs hash into a query string and append it
qs_args = ("#{k}=#{v}" for k, v of qs)
$scope.pdf.qs = if qs_args.length then "?" + qs_args.join("&") else ""
$scope.pdf.url += $scope.pdf.qs
$scope.pdf.url += createQueryString qs
# Save all downloads as files
qs.popupDownload = true
$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
@ -108,17 +172,18 @@ define [
if !response.outputFiles?
return
# prepare list of output files for download dropdown
qs = {}
if response.clsiServerId?
qs.clsiserverid = response.clsiServerId
for file in response.outputFiles
if IGNORE_FILES.indexOf(file.path) == -1
$scope.pdf.outputFiles.push {
# Turn 'output.blg' into 'blg file'.
if file.path.match(/^output\./)
file.name = "#{file.path.replace(/^output\./, "")} file"
else
file.name = file.path
file.url = "/project/#{project_id}/output/#{file.path}"
if response.clsiServerId?
file.url = file.url + "?clsiserverid=#{response.clsiServerId}"
$scope.pdf.outputFiles.push file
name: if file.path.match(/^output\./) then "#{file.path.replace(/^output\./, "")} file" else file.path
url: "/project/#{project_id}/output/#{file.path}" + createQueryString qs
}
fetchLogs = (logFile, blgFile) ->
@ -127,14 +192,17 @@ define [
opts =
method:"GET"
params:
build:file.build
compileGroup:ide.compileGroup
clsiserverid:ide.clsiServerId
if file.url? # FIXME clean this up when we have file.urls out consistently
if file?.url? # FIXME clean this up when we have file.urls out consistently
opts.url = file.url
else if file?.build?
opts.url = "/project/#{$scope.project_id}/build/#{file.build}/output/#{name}"
else
opts.url = "/project/#{$scope.project_id}/output/#{name}"
# check if we need to bust cache (build id is unique so don't need it in that case)
if not file?.build?
opts.params.cache_bust = "#{Date.now()}"
return $http(opts)
# accumulate the log entries
@ -150,7 +218,7 @@ define [
# use the parsers for each file type
processLog = (log) ->
$scope.pdf.rawLog = log
{errors, warnings, typesetting} = LogParser.parse(log, ignoreDuplicates: true)
{errors, warnings, typesetting} = HumanReadableLogs.parse(log, ignoreDuplicates: true)
all = [].concat errors, warnings, typesetting
accumulateResults {all, errors, warnings}
@ -204,7 +272,7 @@ define [
return null
normalizeFilePath = (path) ->
path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/, "")
path = path.replace(/^\/compile\//, "")
rootDocDirname = ide.fileTreeManager.getRootDocDirname()
@ -215,6 +283,9 @@ define [
$scope.recompile = (options = {}) ->
return if $scope.pdf.compiling
event_tracking.sendCountlySampled "editor-recompile-sampled", options
$scope.pdf.compiling = true
ide.$scope.$broadcast("flush-changes")
@ -234,6 +305,21 @@ define [
# This needs to be public.
ide.$scope.recompile = $scope.recompile
# This method is a simply wrapper and exists only for tracking purposes.
ide.$scope.recompileViaKey = () ->
$scope.recompile { keyShortcut: true }
$scope.stop = () ->
return if !$scope.pdf.compiling
$http {
url: "/project/#{$scope.project_id}/compile/stop"
method: "POST"
params:
clsiserverid:ide.clsiServerId
headers:
"X-Csrf-Token": window.csrfToken
}
$scope.clearCache = () ->
$http {
@ -247,6 +333,7 @@ define [
$scope.toggleLogs = () ->
$scope.shouldShowLogs = !$scope.shouldShowLogs
event_tracking.sendCountlyOnce "ide-open-logs-once" if $scope.shouldShowLogs
$scope.showPdf = () ->
$scope.pdf.view = "pdf"
@ -254,6 +341,7 @@ define [
$scope.toggleRawLog = () ->
$scope.pdf.showRawLog = !$scope.pdf.showRawLog
event_tracking.sendCountly "logs-view-raw" if $scope.pdf.showRawLog
$scope.openClearCacheModal = () ->
modalInstance = $modal.open(
@ -287,10 +375,16 @@ define [
$scope.startFreeTrial = (source) ->
ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
event_tracking.sendCountly "subscription-start-trial", { source }
window.open("/user/subscription/new?planCode=student_free_trial_7_days")
$scope.startedFreeTrial = true
App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
# enable per-user containers by default
perUserCompile = true
synctex =
syncToPdf: (cursorPosition) ->
deferred = $q.defer()
@ -339,18 +433,35 @@ define [
deferred.reject()
return deferred.promise
# FIXME: this actually works better if it's halfway across the
# page (or the visible part of the page). Synctex doesn't
# always find the right place in the file when the point is at
# the edge of the page, it sometimes returns the start of the
# next paragraph instead.
h = position.offset.left
# Compute the vertical position to pass to synctex, which
# works with coordinates increasing from the top of the page
# down. This matches the browser's DOM coordinate of the
# click point, but the pdf position is measured from the
# bottom of the page so we need to invert it.
if options.fromPdfPosition and position.pageSize?.height?
v = (position.pageSize.height - position.offset.top) or 0 # measure from pdf point (inverted)
else
v = position.offset.top or 0 # measure from html click position
# It's not clear exactly where we should sync to if it wasn't directly
# clicked on, but a little bit down from the very top seems best.
if options.includeVisualOffset
position.offset.top = position.offset.top + 80
v += 72 # use the same value as in pdfViewer highlighting visual offset
$http({
url: "/project/#{ide.project_id}/sync/pdf",
method: "GET",
params: {
page: position.page + 1
h: position.offset.left.toFixed(2)
v: position.offset.top.toFixed(2)
h: h.toFixed(2)
v: v.toFixed(2)
clsiserverid:ide.clsiServerId
}
})
@ -380,14 +491,15 @@ define [
$scope.syncToCode = () ->
synctex
.syncToCode($scope.pdf.position, includeVisualOffset: true)
.syncToCode($scope.pdf.position, includeVisualOffset: true, fromPdfPosition: true)
.then (data) ->
{doc, line} = data
ide.editorManager.openDoc(doc, gotoLine: line)
]
App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
App.controller "PdfLogEntryController", ["$scope", "ide", "event_tracking", ($scope, ide, event_tracking) ->
$scope.openInEditor = (entry) ->
event_tracking.sendCountlyOnce "logs-jump-to-location-once"
entity = ide.fileTreeManager.findEntityByPath(entry.file)
return if !entity? or entity.type != "doc"
if entry.line?

View file

@ -1,23 +1,14 @@
define [
"base"
"ide/pdfng/directives/pdfViewer"
"text!libs/pdfListView/TextLayer.css"
"text!libs/pdfListView/AnnotationsLayer.css"
"text!libs/pdfListView/HighlightsLayer.css"
], (
App
pdfViewer
textLayerCss
annotationsLayerCss
highlightsLayerCss
) ->
if PDFJS?
PDFJS.workerSrc = window.pdfJsWorkerPath
style = $("<style/>")
style.text(textLayerCss + "\n" + annotationsLayerCss + "\n" + highlightsLayerCss)
$("body").append(style)
App.directive "pdfng", ["$timeout", "localStorage", ($timeout, localStorage) ->
return {
scope: {
@ -27,15 +18,6 @@ define [
"dblClickCallback": "="
}
link: (scope, element, attrs) ->
# pdfListView = new PDFListView element.find(".pdfjs-viewer")[0],
# textLayerBuilder: TextLayerBuilder
# annotationsLayerBuilder: AnnotationsLayerBuilder
# highlightsLayerBuilder: HighlightsLayerBuilder
# ondblclick: (e) -> onDoubleClick(e)
# # logLevel: PDFListView.Logger.DEBUG
# pdfListView.listView.pageWidthOffset = 20
# pdfListView.listView.pageHeightOffset = 20
scope.loading = false
scope.pleaseJumpTo = null
scope.scale = null
@ -50,9 +32,11 @@ define [
scope.scale = { scaleMode: 'scale_mode_fit_width' }
if (position = localStorage("pdf.position.#{attrs.key}"))
scope.position = { page: +position.page, offset: { "top": +position.offset.top, "left": +position.offset.left } }
#scope.position = pdfListView.getPdfPosition(true)
scope.position =
page: +position.page,
offset:
"top": +position.offset.top
"left": +position.offset.left
scope.$on "$destroy", () =>
localStorage "pdf.scale", scope.scale
@ -78,56 +62,18 @@ define [
scope.$watch "pdfSrc", (url) ->
if url
scope.loading = true
# console.log 'pdfSrc =', url
scope.loaded = false
scope.progress = 1
initializePosition()
flashControls()
# pdfListView
# .loadPdf(url, onProgress)
# .then () ->
# scope.$apply () ->
# scope.loading = false
# delete scope.progress
# initializePosition()
# flashControls()
scope.$on "loaded", () ->
scope.loaded = true
scope.progress = 100
scope.$apply()
$timeout () ->
scope.loading = false
delete scope.progress
, 250
#scope.$watch "highlights", (areas) ->
# console.log 'got HIGHLIGHTS in pdfJS', areas
# return if !areas?
# highlights = for area in areas or []
# {
# page: area.page
# highlight:
# left: area.h
# top: area.v
# height: area.height
# width: area.width
# }
# if highlights.length > 0
# first = highlights[0]
# position = {
# page: first.page
# offset:
# left: first.highlight.left
# top: first.highlight.top - 80
# }
# console.log 'position is', position, 'in highlights'
# scope.pleaseJumpTo = position
# pdfListView.clearHighlights()
# pdfListView.setHighlights(highlights, true)
# setTimeout () =>
# pdfListView.clearHighlights()
# , 1000
, 500
scope.fitToHeight = () ->
scale = angular.copy (scope.scale)
@ -155,10 +101,10 @@ define [
for event in attrs.resizeOn.split(",")
scope.$on event, (e) ->
#console.log 'got a resize event', event, e
#
scope.$on 'progress', (event, progress) ->
scope.$apply () ->
return if scope.loaded
scope.progress = Math.floor(progress.loaded/progress.total*100)
scope.progress = 100 if scope.progress > 100
scope.progress = 0 if scope.progress < 0

View file

@ -3,7 +3,7 @@ define [
], (App) ->
# App = angular.module 'pdfPage', ['pdfHighlights']
App.directive 'pdfPage', ['$timeout', 'pdfHighlights', ($timeout, pdfHighlights) ->
App.directive 'pdfPage', ['$timeout', 'pdfHighlights', 'pdfSpinner', ($timeout, pdfHighlights, pdfSpinner) ->
{
require: '^pdfViewer',
template: '''
@ -82,7 +82,7 @@ define [
if scope.timeoutHandler
$timeout.cancel(scope.timeoutHandler)
highlightsLayer.clearHighlights()
scope.timeoutHandler
scope.timeoutHandler = null
# console.log 'got highlight watch in pdfPage', scope.page
pageHighlights = (h for h in highlights when h.page == scope.page.pageNum)

View file

@ -8,11 +8,13 @@ define [
class PDFRenderer
JOB_QUEUE_INTERVAL: 25
PAGE_LOAD_TIMEOUT: 60*1000
PAGE_RENDER_TIMEOUT: 60*1000
INDICATOR_DELAY1: 100 # time to delay before showing the indicator
INDICATOR_DELAY2: 250 # time until the indicator starts animating
constructor: (@url, @options) ->
# PDFJS.disableFontFace = true # avoids repaints, uses worker more
# PDFJS.disableAutoFetch = true # enable this to prevent loading whole file
if @options.disableAutoFetch
PDFJS.disableAutoFetch = true # prevent loading whole file
# PDFJS.disableStream
# PDFJS.disableRange
@scale = @options.scale || 1
@ -28,15 +30,22 @@ define [
@errorCallback = @options.errorCallback
@pageSizeChangeCallback = @options.pageSizeChangeCallback
@pdfjs.promise.catch (exception) =>
# console.log 'ERROR in get document', exception
# error getting document
@errorCallback(exception)
resetState: () ->
@complete = []
@timeout = []
@pageLoad = []
@renderTask = []
@renderQueue = []
clearTimeout @queueTimer if @queueTimer?
# clear any existing timers, render tasks
for timer in @spinTimer or []
clearTimeout timer
for page in @pageState or []
page?.loadTask?.cancel()
page?.renderTask?.cancel()
# initialise/reset the state
@pageState = []
@spinTimer = [] # timers for starting the spinners (to avoid jitter)
@spinTimerDone = [] # array of pages where the spinner has activated
@jobs = 0
getNumPages: () ->
@ -45,7 +54,6 @@ define [
getPage: (pageNum) ->
@document.then (pdfDocument) ->
# console.log 'got pdf document, now getting Page', pageNum
pdfDocument.getPage(pageNum)
getPdfViewport: (pageNum, scale) ->
@ -80,14 +88,14 @@ define [
@resetState()
triggerRenderQueue: (interval = @JOB_QUEUE_INTERVAL) ->
if @queueTimer?
clearTimeout @queueTimer
@queueTimer = setTimeout () =>
@queueTimer = null
@processRenderQueue()
, interval
removeCompletedJob: (taskRef, pagenum) ->
# may need to clean up deferred object here
delete taskRef[pagenum]
removeCompletedJob: (pagenum) ->
@jobs = @jobs - 1
@triggerRenderQueue(0)
@ -109,69 +117,129 @@ define [
@renderQueue.push current
@processRenderQueue()
getPageDetails: (page) ->
return [page.element.canvas, page.pagenum]
# handle the loading indicators for each page
startIndicators: () ->
# make an array of the pages in the queue
@queuedPages = []
@queuedPages[page.pagenum] = true for page in @renderQueue
# clear any unfinished spinner timers on pages that aren't in the queue any more
for pagenum of @spinTimer when not @queuedPages[pagenum]
clearTimeout @spinTimer[pagenum]
delete @spinTimer[pagenum]
# add indicators for any new pages in the current queue
for page in @renderQueue when not @spinTimer[page.pagenum] and not @spinTimerDone[page.pagenum]
@startIndicator page
startIndicator: (page) ->
[canvas, pagenum] = @getPageDetails page
canvas.addClass('pdfng-loading')
@spinTimer[pagenum] = setTimeout () =>
for queuedPage in @renderQueue
if pagenum == queuedPage.pagenum
@spinner.add(canvas, {static:true})
@spinTimerDone[pagenum] = true
break
delete @spinTimer[pagenum]
, @INDICATOR_DELAY1
updateIndicator: (page) ->
[canvas, pagenum] = @getPageDetails page
# did the spinner insert itself already?
if @spinTimerDone[pagenum]
@spinTimer[pagenum] = setTimeout () =>
@spinner.start(canvas)
delete @spinTimer[pagenum]
, @INDICATOR_DELAY2
else
# stop the existing spin timer
clearTimeout @spinTimer[pagenum]
# start a new one which will also start spinning
@spinTimer[pagenum] = setTimeout () =>
@spinner.add(canvas, {static:true})
@spinTimerDone[pagenum] = true
@spinTimer[pagenum] = setTimeout () =>
@spinner.start(canvas)
delete @spinTimer[pagenum]
, @INDICATOR_DELAY2
, @INDICATOR_DELAY1
clearIndicator: (page) ->
[canvas, pagenum] = @getPageDetails page
@spinner.stop(canvas)
clearTimeout @spinTimer[pagenum]
delete @spinTimer[pagenum]
@spinTimerDone[pagenum] = true
# handle the queue of pages to be rendered
processRenderQueue: () ->
return if @shuttingDown
# mark all pages in the queue as loading
@startIndicators()
# bail out if there is already a render job running
return if @jobs > 0
current = @renderQueue.shift()
return unless current?
[element, pagenum] = [current.element, current.pagenum]
# if task is underway or complete, go to the next entry in the
# render queue
# console.log 'processing renderq', pagenum, @renderTask[pagenum], @complete[pagenum]
if @pageLoad[pagenum] or @renderTask[pagenum] or @complete[pagenum]
@processRenderQueue()
return
# take the first page in the queue
page = @renderQueue.shift()
# check if it is in action already
while page? and @pageState[page.pagenum]?
page = @renderQueue.shift()
return unless page?
[element, pagenum] = [page.element, page.pagenum]
@jobs = @jobs + 1
element.canvas.addClass('pdfng-loading')
spinTimer = setTimeout () =>
@spinner.add(element.canvas)
, 100
completeRef = @complete
renderTaskRef = @renderTask
# console.log 'started page load', pagenum
# update the spinner to make it spinning (signifies loading has begun)
@updateIndicator page
timedOut = false
timer = $timeout () =>
Raven?.captureMessage?('pdfng page load timed out after ' + @PAGE_LOAD_TIMEOUT + 'ms (1% sample)') if Math.random() < 0.01
# console.log 'page load timed out', pagenum
timer = $timeout () => # page load timed out
return if loadTask.cancelled # return from cancelled page load
Raven?.captureMessage?('pdfng page load timed out after ' + @PAGE_LOAD_TIMEOUT + 'ms')
timedOut = true
clearTimeout(spinTimer)
@spinner.stop(element.canvas)
@clearIndicator page
# @jobs = @jobs - 1
# @triggerRenderQueue(0)
@errorCallback?('timeout')
, @PAGE_LOAD_TIMEOUT
@pageLoad[pagenum] = @getPage(pagenum)
loadTask = @getPage(pagenum)
@pageLoad[pagenum].then (pageObject) =>
# console.log 'in page load success', pagenum
loadTask.cancel = () ->
@cancelled = true
@pageState[pagenum] = pageState = { loadTask: loadTask }
loadTask.then (pageObject) =>
# page load success
$timeout.cancel(timer)
clearTimeout(spinTimer)
@renderTask[pagenum] = @doRender element, pagenum, pageObject
@renderTask[pagenum].then () =>
# complete
# console.log 'render task success', pagenum
completeRef[pagenum] = true
@removeCompletedJob renderTaskRef, pagenum
return if loadTask.cancelled # return from cancelled page load
pageState.renderTask = @doRender element, pagenum, pageObject
pageState.renderTask.then () =>
# render task success
@clearIndicator page
pageState.complete = true
delete pageState.renderTask
@removeCompletedJob pagenum
, () =>
# console.log 'render task failed', pagenum
# rejected
@removeCompletedJob renderTaskRef, pagenum
.catch (error) ->
# console.log 'in page load error', pagenum, 'timedOut=', timedOut
# render task failed
# could display an error icon
pageState.complete = false
delete pageState.renderTask
@removeCompletedJob pagenum
.catch (error) =>
# page load error
$timeout.cancel(timer)
clearTimeout(spinTimer)
# console.log 'ERROR', error
@clearIndicator page
doRender: (element, pagenum, page) ->
self = this
scale = @scale
if (not scale?)
# console.log 'scale is undefined, returning'
# scale is undefined, returning
return
canvas = $('<canvas class="pdf-canvas pdfng-rendering"></canvas>')
@ -220,28 +288,15 @@ define [
navigateFn: @navigateFn
})
element.canvas.replaceWith(canvas)
# console.log 'staring page render', pagenum
result = page.render {
canvasContext: ctx
viewport: viewport
transform: [pixelRatio, 0, 0, pixelRatio, 0, 0]
}
timedOut = false
timer = $timeout () =>
Raven?.captureMessage?('pdfng page render timed out after ' + @PAGE_RENDER_TIMEOUT + 'ms (1% sample)') if Math.random() < 0.01
# console.log 'page render timed out', pagenum
timedOut = true
result.cancel()
, @PAGE_RENDER_TIMEOUT
result.then () ->
# console.log 'page rendered', pagenum
$timeout.cancel(timer)
# page render success
element.canvas.replaceWith(canvas)
canvas.removeClass('pdfng-rendering')
page.getTextContent().then (textContent) ->
textLayer.setTextContent textContent
@ -252,24 +307,17 @@ define [
, (error) ->
self.errorCallback?(error)
.catch (error) ->
# console.log 'page render failed', pagenum, error
$timeout.cancel(timer)
if timedOut
# console.log 'calling ERROR callback - was timeout'
self.errorCallback?('timeout')
else if error != 'cancelled'
# console.log 'calling ERROR callback'
# page render failed
if error is 'cancelled'
return # do nothing when cancelled
else
self.errorCallback?(error)
return result
destroy: () ->
# console.log 'in pdf renderer destroy', @renderQueue
@shuttingDown = true
clearTimeout @queueTimer if @queueTimer?
@renderQueue = []
for task in @renderTask
task.cancel() if task?
@resetState()
@pdfjs.then (document) ->
document.cleanup()
document.destroy()

View file

@ -8,14 +8,15 @@ define [
constructor: () ->
# handler for spinners
add: (element) ->
h = element.height()
w = element.width()
add: (element, options) ->
size = 64
spinner = $('<div class="pdfng-spinner" style="position: absolute; top: 50%; left:50%; transform: translateX(-50%) translateY(-50%);"><i class="fa fa-spinner fa-spin" style="color: #999"></i></div>')
spinner = $('<div class="pdfng-spinner" style="position: absolute; top: 50%; left:50%; transform: translateX(-50%) translateY(-50%);"><i class="fa fa-spinner' + (if options?.static then '' else ' fa-spin') + '" style="color: #999"></i></div>')
spinner.css({'font-size' : size + 'px'})
element.append(spinner)
start: (element) ->
element.find('.fa-spinner').addClass('fa-spin')
stop: (element) ->
element.find('.fa-spinner').removeClass('fa-spin')

View file

@ -27,8 +27,15 @@ define [
$scope.document.destroy() if $scope.document?
$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
# TODO need a proper url manipulation library to add to query string
$scope.document = new PDFRenderer($scope.pdfSrc + '&pdfng=true' , {
url = $scope.pdfSrc
# add 'pdfng=true' to show that we are using the angular pdfjs viewer
queryStringExists = url.match(/\?/)
url = url + (if not queryStringExists then '?' else '&') + 'pdfng=true'
# for isolated compiles, load the pdf on-demand because nobody will overwrite it
onDemandLoading = true
$scope.document = new PDFRenderer(url, {
scale: 1,
disableAutoFetch: if onDemandLoading then true else undefined
navigateFn: (ref) ->
# this function captures clicks on the annotation links
$scope.navigateTo = ref
@ -38,7 +45,7 @@ define [
loadedCallback: () ->
$scope.$emit 'loaded'
errorCallback: (error) ->
Raven?.captureMessage?('pdfng error ' + error + ' (1% sample)') if Math.random() < 0.01
Raven?.captureMessage?('pdfng error ' + error)
$scope.$emit 'pdf:error', error
pageSizeChangeCallback: (pageNum, deltaH) ->
$scope.$broadcast 'pdf:page:size-change', pageNum, deltaH
@ -57,6 +64,7 @@ define [
result.pdfViewport.width
]
# console.log 'resolved q.all, page size is', result
$scope.$emit 'loaded'
$scope.numPages = result.numPages
.catch (error) ->
$scope.$emit 'pdf:error', error
@ -187,7 +195,8 @@ define [
# console.log 'converted to offset = ', pdfOffset
newPosition = {
"page": topPageIdx,
"offset" : { "top" : pdfOffset[1], "left": 0 }
"offset" : { "top" : pdfOffset[1], "left": 0}
"pageSize": { "height": viewport.viewBox[3], "width": viewport.viewBox[2] }
}
return newPosition
@ -208,6 +217,8 @@ define [
return $scope.document.getPdfViewport(page.pageNum).then (viewport) ->
page.viewport = viewport
pageOffset = viewport.convertToViewportPoint(offset.left, offset.top)
# if the passed-in position doesn't have the page height/width add them now
position.pageSize ?= {"height": viewport.viewBox[3], "width": viewport.viewBox[2]}
# console.log 'addition offset =', pageOffset
# console.log 'total', pageTop + pageOffset[1]
Math.round(pageTop + pageOffset[1] + currentScroll) ## 10 is margin
@ -246,8 +257,8 @@ define [
# console.log 'layoutReady was resolved'
renderVisiblePages = () ->
pages = getVisiblePages()
# pages = getExtraPages visiblePages
visiblePages = getVisiblePages()
pages = getExtraPages visiblePages
scope.document.renderPages(pages)
getVisiblePages = () ->
@ -264,6 +275,7 @@ define [
getExtraPages = (visiblePages) ->
extra = []
if visiblePages.length > 0
firstVisiblePage = visiblePages[0].pageNum
firstVisiblePageIdx = firstVisiblePage - 1
len = visiblePages.length
@ -513,8 +525,17 @@ define [
first = highlights[0]
pageNum = scope.pages[first.page].pageNum
# switching between split and full pdf views can cause
# highlights to appear before rendering
if !scope.pages
return # ignore highlight scroll if still rendering
pageNum = scope.pages[first.page]?.pageNum
if !pageNum?
return # ignore highlight scroll if page not found
# use a visual offset of 72pt to match the offset in PdfController syncToCode
scope.document.getPdfViewport(pageNum).then (viewport) ->
position = {
page: first.page

View file

@ -1,19 +1,43 @@
define [
"base"
], (App) ->
App.factory "settings", ["ide", (ide) ->
App.factory "settings", ["ide", "event_tracking", (ide, event_tracking) ->
return {
saveSettings: (data) ->
# Tracking code.
for key in Object.keys(data)
changedSetting = key
changedSettingVal = data[key]
event_tracking.sendCountly "setting-changed", { changedSetting, changedSettingVal }
# End of tracking code.
data._csrf = window.csrfToken
ide.$http.post "/user/settings", data
saveProjectSettings: (data) ->
# Tracking code.
for key in Object.keys(data)
changedSetting = key
changedSettingVal = data[key]
event_tracking.sendCountly "project-setting-changed", { changedSetting, changedSettingVal}
# End of tracking code.
data._csrf = window.csrfToken
ide.$http.post "/project/#{ide.project_id}/settings", data
saveProjectAdminSettings: (data) ->
# Tracking code.
for key in Object.keys(data)
changedSetting = key
changedSettingVal = data[key]
event_tracking.sendCountly "project-admin-setting-changed", { changedSetting, changedSettingVal }
# End of tracking code.
data._csrf = window.csrfToken
ide.$http.post "/project/#{ide.project_id}/settings/admin", data
}
]

View file

@ -1,8 +1,10 @@
define [
"base"
], (App) ->
App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) ->
$scope.openShareProjectModal = () ->
event_tracking.sendCountlyOnce "ide-open-share-modal-once"
$modal.open(
templateUrl: "shareProjectModalTemplate"
controller: "ShareProjectModalController"

View file

@ -16,3 +16,4 @@ define [
"libs/angular-sixpack"
"libs/ng-tags-input-3.0.0"
], () ->

View file

@ -1,11 +1,13 @@
define [
"base"
], (App) ->
App.controller "FreeTrialModalController", ($scope, abTestManager, sixpack)->
App.controller "FreeTrialModalController", ($scope, abTestManager, sixpack, event_tracking)->
$scope.buttonClass = "btn-primary"
$scope.startFreeTrial = (source, couponCode) ->
event_tracking.sendCountly "subscription-start-trial", { source }
w = window.open()
sixpack.convert "track-changes-discount", ->
sixpack.participate 'in-editor-free-trial-plan', ['student', 'collaborator'], (planName, rawResponse)->

View file

@ -1,9 +1,8 @@
define [
"base"
"libs/platform"
"services/algolia-search"
], (App, platform) ->
App.controller 'ContactModal', ($scope, $modal) ->
$scope.contactUsModal = () ->
modalInstance = $modal.open(
@ -11,10 +10,25 @@ define [
controller: "SupportModalController"
)
App.controller 'SupportModalController', ($scope, $modalInstance) ->
App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
$scope.form = {}
$scope.sent = false
$scope.sending = false
$scope.suggestions = [];
_handleSearchResults = (success, results) ->
suggestions = for hit in results.hits
page_underscored = hit.pageName.replace(/\s/g,'_')
suggestion =
url :"/learn/kb/#{page_underscored}"
name : hit._highlightResult.pageName.value
event_tracking.sendCountly "contact-form-suggestions-shown" if results.hits.length
$scope.$applyAsync () ->
$scope.suggestions = suggestions
$scope.contactUs = ->
if !$scope.form.email?
console.log "email not set"
@ -36,6 +50,18 @@ define [
$scope.sent = true
$scope.$apply()
$scope.$watch "form.subject", (newVal, oldVal) ->
if newVal and newVal != oldVal and newVal.length > 3
algoliaSearch.searchKB newVal, _handleSearchResults, {
hitsPerPage: 3
typoTolerance: 'strict'
}
else
$scope.suggestions = [];
$scope.clickSuggestionLink = (url) ->
event_tracking.sendCountly "contact-form-suggestions-clicked", { url }
$scope.close = () ->
$modalInstance.close()

View file

@ -1,17 +1,61 @@
define [
"base"
"modules/localStorage"
], (App) ->
CACHE_KEY = "countlyEvents"
App.factory "event_tracking", (localStorage) ->
_getEventCache = () ->
eventCache = localStorage CACHE_KEY
# Initialize as an empy object if the event cache is still empty.
if !eventCache?
eventCache = {}
localStorage CACHE_KEY, eventCache
return eventCache
_eventInCache = (key) ->
curCache = _getEventCache()
curCache[key] || false
_addEventToCache = (key) ->
curCache = _getEventCache()
curCache[key] = true
localStorage CACHE_KEY, curCache
App.factory "event_tracking", ->
return {
send: (category, action, label, value)->
ga('send', 'event', category, action, label, value)
sendCountly: (key, segmentation) ->
eventData = { key }
eventData.segmentation = segmentation if segmentation?
Countly?.q.push([ "add_event", eventData ])
sendCountlySampled: (key, segmentation) ->
@sendCountly key, segmentation if Math.random() < .01
sendCountlyOnce: (key, segmentation) ->
if ! _eventInCache(key)
_addEventToCache(key)
@sendCountly key, segmentation
}
# App.directive "countlyTrack", () ->
# return {
# restrict: "A"
# scope: false,
# link: (scope, el, attrs) ->
# eventKey = attrs.countlyTrack
# if (eventKey?)
# el.on "click", () ->
# console.log eventKey
# }
#header
$('.navbar a').on "click", (e)->
href = $(e.target).attr("href")
if href?
ga('send', 'event', 'navigation', 'top menu bar', href)

View file

@ -1,15 +1,9 @@
define [
"base"
"services/algolia-search"
], (App) ->
App.factory "algoliawiki", ->
if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
return index
App.controller "SearchWikiController", ($scope, algoliawiki, _, $modal) ->
algolia = algoliawiki
App.controller "SearchWikiController", ($scope, algoliaSearch, _, $modal) ->
$scope.hits = []
$scope.clearSearchText = ->
@ -54,7 +48,7 @@ define [
updateHits []
return
algolia.search query, (err, response)->
algoliaSearch.searchWiki query, (err, response)->
if response.hits.length == 0
updateHits []
else

View file

@ -2,7 +2,7 @@ define [
"base"
], (App)->
App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack)->
App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking)->
throw new Error("Recurly API Library Missing.") if typeof recurly is "undefined"
$scope.currencyCode = MultiCurrencyPricing.currencyCode
@ -51,6 +51,8 @@ define [
.done()
pricing.on "change", =>
event_tracking.sendCountly "subscription-form", { plan : pricing.items.plan.code }
$scope.planName = pricing.items.plan.name
$scope.price = pricing.price
$scope.trialLength = pricing.items.plan.trial?.length
@ -115,9 +117,25 @@ define [
currencyCode:pricing.items.currency
plan_code:pricing.items.plan.code
coupon_code:pricing.items?.coupon?.code || ""
isPaypal: $scope.paymentMethod == 'paypal'
address:
address1: $scope.data.address1
address2: $scope.data.address2
country: $scope.data.country
state: $scope.data.state
postal_code: $scope.data.postal_code
event_tracking.sendCountly "subscription-form-submitted", {
currencyCode : postData.subscriptionDetails.currencyCode,
plan_code : postData.subscriptionDetails.plan_code,
coupon_code : postData.subscriptionDetails.coupon_code,
isPaypal : postData.subscriptionDetails.isPaypal
}
$http.post("/user/subscription/create", postData)
.success (data, status, headers)->
sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)->
event_tracking.sendCountly "subscription-submission-success"
window.location.href = "/user/subscription/thank-you"
.error (data, status, headers)->
$scope.processing = false

View file

@ -4,7 +4,6 @@ define [
App.controller 'LeftHandMenuPromoController', ($scope) ->
$scope.showDatajoy = Math.random() < 0.5
$scope.hasProjects = window.data.projects.length > 0
$scope.userHasSubscription = window.userHasSubscription
$scope.userHasNoSubscription = window.userHasNoSubscription
$scope.randomView = _.shuffle(["default", "dropbox", "github"])[0]

View file

@ -1,15 +1,9 @@
define [
"base"
"services/algolia-search"
], (App) ->
App.factory "algoliawiki", ->
if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
return index
App.controller "SearchWikiController", ($scope, algoliawiki, _) ->
algolia = algoliawiki
App.controller "SearchWikiController", ($scope, algoliaSearch, _) ->
$scope.hits = []
$scope.clearSearchText = ->
@ -54,7 +48,7 @@ define [
updateHits []
return
algolia.search query, (err, response)->
algoliaSearch.searchWiki query, (err, response)->
if response.hits.length == 0
updateHits []
else

View file

@ -0,0 +1,14 @@
define [
"base"
], (App) ->
App.factory "algoliaSearch", ->
if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
wikiIdx = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
service =
searchWiki: wikiIdx.search.bind(wikiIdx)
searchKB: kbIdx.search.bind(kbIdx)
return service

View file

@ -0,0 +1,58 @@
define [
"base"
], (App) ->
App.factory "logHintsFeedback", ($http, $q) ->
hintsFeedbackFormAPIHash = "rl4xgvr1v5t64a"
idStampVal = "OPkEWEFHUFAm7hKlraQMhiOXQabafWo8NipRvLT397w="
hintFieldAPIId = "3"
reasonFieldAPIId = "1"
reasonOtherFieldAPIId = "1_other_other"
submitEndpoint = "https://sharelatex.wufoo.eu/forms/#{ hintsFeedbackFormAPIHash }/#public"
feedbackOpts =
DIDNT_UNDERSTAND: "didnt_understand"
NOT_APPLICABLE: "not_applicable"
INCORRECT: "incorrect"
OTHER: "other"
createRequest = (hintId, feedbackOpt, feedbackOtherVal = "") ->
formData = new FormData()
formData.append "Field#{ hintFieldAPIId }", hintId
formData.append "Field#{ reasonFieldAPIId }", feedbackOpt
formData.append "idstamp", idStampVal
# Allow user to specify "other" without any extra details; an empty string
# would trigger an error submitting.
if feedbackOpt == feedbackOpts.OTHER and feedbackOtherVal == ""
formData.append "Field#{ reasonOtherFieldAPIId }", "#{ feedbackOpts.OTHER } empty"
else
formData.append "Field#{ reasonOtherFieldAPIId }", feedbackOtherVal
req =
method: 'POST'
url: submitEndpoint
# This will effectively disable Angular's default serialization mechanisms,
# forcing the XHR to be done with whatever data we provide (in this case,
# form data). Without this, Angular will forcefully try to serialize data
# to JSON.
transformRequest: angular.identity
data: formData
headers :
# This will tell Angular to use the browser-provided value, which is
# computed according to the data being sent (in this case, multipart
# form + browser-specific multipart boundary). Without this, Angular
# will set JSON.
"Content-Type": undefined
return req
submitFeedback = (hintId, feedbackOpt, feedbackOtherVal = "") ->
submitRequest = createRequest hintId, feedbackOpt, feedbackOtherVal
$http(submitRequest)
service =
feedbackOpts: feedbackOpts
submitFeedback: submitFeedback
return service

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Some files were not shown because too many files have changed in this diff Show more