codecombat/server/commons/database.coffee
Phoenix Eliot 663c220eaf Show wev-dev iFrame error messages like Aether's
This heavily refactors SpellView and adds infrastructure for receiving and reporting Errors raised by the web-dev iFrame. The web-dev error system, the Aether error system, and the Ace html-worker avoid disturbing each others' errors/annotations (though currently Aether+web-dev errors won't coexist), and they clear/update their own asynchronously.

Show web-dev iFrame errors as Ace annotations

Add functional error banners (with poor messages)

Improve error banners, don't allow duplicate Problems

Refactor setAnnotations override

Convert all constructor calls for Problems

Add comments, clean up

Clean up

Don't clear things unnecessarily

Clean up error message sending from iFrame

Add web-dev:error schema

Clarify error message attributes

Refactor displaying AetherProblems

Refactor displaying user problem banners

Refactor onWebDevError

Set ace styles on updating @problems

Clean up, fix off-by-1 error

Add comment

Show stale web-dev errors differently
Some web-dev errors are generated by "stale" code — code that's still running in the iFrame but doesn't have the player's recent changes.
This shows those errors differently than if they weren't "stale", and suggests they re-run their code.

Hook up web-dev event schema

Destroy ignored duplicate problems

Functionalize a bit of stuff

Fix ProblemAlertView never loading
2016-08-31 10:59:06 -07:00

208 lines
8.1 KiB
CoffeeScript

config = require '../../server_config'
winston = require 'winston'
mongoose = require 'mongoose'
Grid = require 'gridfs-stream'
mongooseCache = require 'mongoose-cache'
errors = require '../commons/errors'
Promise = require 'bluebird'
_ = require 'lodash'
module.exports =
isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24
connect: () ->
address = module.exports.generateMongoConnectionString()
winston.info "Connecting to Mongo with connection string #{address}, readpref: #{config.mongo.readpref}"
mongoose.connect address
mongoose.connection.once 'open', -> Grid.gfs = Grid(mongoose.connection.db, mongoose.mongo)
# Hack around Mongoose not exporting Aggregate so that we can patch its exec, too
# https://github.com/LearnBoost/mongoose/issues/1910
Level = require '../models/Level'
Aggregate = Level.aggregate().constructor
maxAge = (Math.random() * 10 + 10) * 60 * 1000 # Randomize so that each server doesn't refresh cache from db at same times
mongooseCache.install(mongoose, {max: 1000, maxAge: maxAge, debug: false}, Aggregate)
generateMongoConnectionString: ->
if not global.testing and config.mongo.mongoose_replica_string
address = config.mongo.mongoose_replica_string
else
dbName = config.mongo.db
dbName += '_unittest' if global.testing
address = config.mongo.host + ':' + config.mongo.port
if config.mongo.username and config.mongo.password
address = config.mongo.username + ':' + config.mongo.password + '@' + address
address = "mongodb://#{address}/#{dbName}"
return address
initDoc: (req, Model) ->
# TODO: Move to model superclass or plugins?
doc = new Model({})
if Model.schema.is_patchable
watchers = [req.user.get('_id')]
if req.user.isAdmin() # https://github.com/codecombat/codecombat/issues/1105
nick = mongoose.Types.ObjectId('512ef4805a67a8c507000001')
watchers.push nick unless _.find watchers, (id) -> id.equals nick
doc.set 'watchers', watchers
if Model.schema.uses_coco_versions
doc.set('original', doc._id)
doc.set('creator', req.user._id)
return doc
applyCustomSearchToDBQ: (req, dbq) ->
specialParameters = ['term', 'project', 'conditions']
return unless req.user?.isAdmin()
return unless req.query.filter or req.query.conditions
# admins can send any sort of query down the wire
# Example URL: http://localhost:3000/db/user?filter[anonymous]=true
filter = {}
if 'filter' of req.query
for own key, val of req.query.filter
if key not in specialParameters
try
filter[key] = JSON.parse(val)
catch SyntaxError
throw new errors.UnprocessableEntity("Could not parse filter for key '#{key}'.")
dbq.find(filter)
# Conditions are chained query functions, for example: query.find().limit(20).sort('-dateCreated')
# Example URL: http://localhost:3000/db/user?conditions[limit]=20&conditions[sort]="-dateCreated"
for own key, val of req.query.conditions
if not dbq[key]
throw new errors.UnprocessableEntity("No query condition '#{key}'.")
try
val = JSON.parse(val)
dbq[key](val)
catch SyntaxError
throw new errors.UnprocessableEntity("Could not parse condition for key '#{key}'.")
viewSearch: Promise.promisify (dbq, req, done) ->
Model = dbq.model
# TODO: Make this function only alter dbq or returns a find. It should not also execute the query.
term = req.query.term
matchedObjects = []
filters = if Model.schema.uses_coco_versions or Model.schema.uses_coco_permissions then [filter: {index: true}] else [filter: {}]
if Model.schema.uses_coco_permissions and req.user
filters.push {filter: {index: req.user.get('id')}}
for filter in filters
callback = (err, results) ->
return done(new errors.InternalServerError('Error fetching search results.', {err: err})) if err
for r in results.results ? results
obj = r.obj ? r
continue if obj in matchedObjects # TODO: probably need a better equality check
matchedObjects.push obj
filters.pop() # doesn't matter which one
unless filters.length
done(null, matchedObjects)
if term
filter.filter.$text = $search: term
else if filters.length is 1 and filters[0].filter?.index is true
# All we are doing is an empty text search, but that doesn't hit the index,
# so we'll just look for the slug.
filter.filter = slug: {$exists: true}
# This try/catch is here to handle when a custom search tries to find by slug. TODO: Fix this more gracefully.
try
dbq.find filter.filter
catch
dbq.exec callback
assignBody: (req, doc, options={}) ->
if _.isEmpty(req.body)
throw new errors.UnprocessableEntity('No input')
if not doc.schema.statics.editableProperties
console.warn 'No editableProperties set for', doc.constructor.modelName
props = (doc.schema.statics.editableProperties or []).slice()
if doc.isNew
props = props.concat(doc.schema.statics.postEditableProperties or [])
if not doc.schema.statics.postEditableProperties
console.warn 'No postEditableProperties set for', doc.constructor.modelName
if doc.schema.uses_coco_permissions and req.user
isOwner = doc.getAccessForUserObjectId(req.user._id) is 'owner'
if doc.isNew or isOwner or req.user?.isAdmin()
props.push 'permissions'
props.push 'commitMessage' if doc.schema.uses_coco_versions
props.push 'allowPatches' if doc.schema.is_patchable
for prop in props
if (val = req.body[prop])?
doc.set prop, val
else if options.unsetMissing and doc.get(prop)?
doc.set prop, undefined
validateDoc: (doc) ->
obj = doc.toObject()
# Hack to get saving of Users to work. Probably should replace these props with strings
# so that validation doesn't get hung up on Date objects in the documents.
delete obj.dateCreated
tv4 = require('tv4').tv4
result = tv4.validateMultiple(obj, doc.schema.statics.jsonSchema)
if not result.valid
prunedErrors = (_.omit(error, 'stack') for error in result.errors)
winston.debug('Validation errors: ', JSON.stringify(prunedErrors, null, '\t'))
throw new errors.UnprocessableEntity('JSON-schema validation failed', { validationErrors: result.errors })
getDocFromHandle: Promise.promisify (req, Model, options, done) ->
if _.isFunction(options)
done = options
options = {}
dbq = Model.find()
handle = req.params.handle
if not handle
return done(new errors.UnprocessableEntity('No handle provided.'))
if @isID(handle)
dbq.findOne({ _id: handle })
else
dbq.findOne({ slug: handle })
if options.select
dbq.select(options.select)
dbq.exec(done)
hasAccessToDocument: (req, doc, method) ->
method = method or req.method
return true if req.user?.isAdmin()
if doc.schema.uses_coco_translation_coverage and method in ['post', 'put']
return true if @isJustFillingTranslations(req, doc)
if doc.schema.uses_coco_permissions
return doc.hasPermissionsForMethod?(req.user, method)
return true
isJustFillingTranslations: (req, doc) ->
deltasLib = require '../../app/core/deltas'
differ = deltasLib.makeJSONDiffer()
omissions = ['original'].concat(deltasLib.DOC_SKIP_PATHS)
delta = differ.diff(_.omit(doc.toObject(), omissions), _.omit(req.body, omissions))
flattened = deltasLib.flattenDelta(delta)
_.all flattened, (delta) ->
# sometimes coverage gets moved around... allow other changes to happen to i18nCoverage
return false unless _.isArray(delta.o)
return true if 'i18nCoverage' in delta.dataPath
return false unless delta.o.length is 1
index = delta.deltaPath.indexOf('i18n')
return false if index is -1
return false if delta.deltaPath[index+1] in ['en', 'en-US', 'en-GB'] # English speakers are most likely just spamming, so always treat those as patches, not saves.
return true