const crypto = require('crypto') const path = require('path') const config = require('../../config') const fs = require('fs') const { Op } = require('sequelize') const linkifyHtml = require('linkify-html') const Sequelize = require('sequelize') const dayjs = require('dayjs') const helpers = require('../../helpers') const Col = helpers.col const notifier = require('../../notifier') const { Event, Resource, Tag, Place, Notification, APUser } = require('../models/models') const exportController = require('./export') const tagController = require('./tag') const log = require('../../log') const eventController = { async _findOrCreatePlace (body) { if (body.place_id) { const place = await Place.findByPk(body.place_id) if (!place) { throw new Error(`Place not found`) } return place } const place_name = body.place_name && body.place_name.trim() const place_address = body.place_address && body.place_address.trim() if (!place_address || !place_name) { throw new Error(`place_id or place_name and place_address are required`) } let place = await Place.findOne({ where: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), Sequelize.Op.eq, place_name.toLocaleLowerCase()) }) if (!place) { place = await Place.create({ name: place_name, address: place_address, latitude: Number(body.place_latitude) || null, longitude: Number(body.place_longitude) || null }) } return place }, async searchMeta(req, res) { const search = req.query.search const places = await Place.findAll({ order: [[Sequelize.col('w'), 'DESC']], where: { [Op.or]: [ Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + search + '%'), Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('address')), 'LIKE', '%' + search + '%') ] }, attributes: [['name', 'label'], 'address', 'latitude', 'longitude', 'id', [Sequelize.cast(Sequelize.fn('COUNT', Sequelize.col('events.placeId')), 'INTEGER'), 'w']], include: [{ model: Event, where: { is_visible: true }, required: true, attributes: [] }], group: ['place.id'], raw: true }) const tags = await Tag.findAll({ order: [[Sequelize.col('w'), 'DESC']], where: { tag: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('tag')), 'LIKE', '%' + search + '%'), }, attributes: [['tag', 'label'], [Sequelize.cast(Sequelize.fn('COUNT', Sequelize.col('tag.tag')), 'INTEGER'), 'w']], include: [{ model: Event, where: { is_visible: true }, attributes: [], through: { attributes: [] }, required: true }], group: ['tag.tag'], raw: true }) const ret = places.map(p => { p.type = 'place' return p }).concat(tags.map(t => { t.type = 'tag' return t })).sort((a, b) => b.w - a.w).slice(0, 10) return res.json(ret) }, // async search(req, res) { // const search = req.query.search.trim().toLocaleLowerCase() // const show_recurrent = req.query.show_recurrent || false // const end = req.query.end // const replacements = [] // const where = { // // do not include parent recurrent event // recurrent: null, // // confirmed event only // is_visible: true, // } // if (!show_recurrent) { // where.parentId = null // } // if (end) { // where.start_datetime = { [Op.lte]: end } // } // if (search) { // replacements.push(search) // where[Op.or] = // [ // { title: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('title')), 'LIKE', '%' + search + '%') }, // Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + search + '%'), // Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) = ?`)) // ] // } // const events = await Event.findAll({ // where, // attributes: { // exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'updatedAt', 'description', 'resources'] // }, // order: [['start_datetime', 'DESC']], // include: [ // { // model: Tag, // // order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')], // attributes: ['tag'], // through: { attributes: [] } // }, // { model: Place, required: true, attributes: ['id', 'name', 'address', 'latitude', 'longitude'] } // ], // replacements, // limit: 30, // }).catch(e => { // log.error('[EVENT]', e) // return res.json([]) // }) // const ret = events.map(e => { // e = e.get() // e.tags = e.tags ? e.tags.map(t => t && t.tag) : [] // return e // }) // return res.json(ret) // }, async _get(slug) { // retrocompatibility, old events URL does not use slug, use id as fallback const id = Number(slug) || -1 return Event.findOne({ where: { [Op.or]: { slug, id } } }) }, async get(req, res) { const format = req.params.format || 'json' const is_admin = req.user && req.user.is_admin const slug = req.params.event_slug // retrocompatibility, old events URL does not use slug, use id as fallback const id = Number(slug) || -1 let event try { event = await Event.findOne({ where: { [Op.or]: { slug, id } }, attributes: { exclude: ['createdAt', 'updatedAt', 'placeId'] }, include: [ { model: Tag, required: false, attributes: ['tag'], through: { attributes: [] } }, { model: Place, attributes: ['name', 'address', 'latitude', 'longitude', 'id'] }, { model: Resource, where: !is_admin && { hidden: false }, include: [{ model: APUser, required: false, attributes: ['object', 'ap_id'] }], required: false, attributes: ['id', 'activitypub_id', 'data', 'hidden'] }, { model: Event, required: false, as: 'parent', attributes: ['id', 'recurrent', 'is_visible', 'start_datetime'] } ], order: [[Resource, 'id', 'DESC']] }) } catch (e) { log.error('[EVENT]', e) return res.sendStatus(400) } if (!event) { return res.sendStatus(404) } // get prev and next event const next = await Event.findOne({ attributes: ['id', 'slug'], where: { id: { [Op.not]: event.id }, is_visible: true, recurrent: null, [Op.or]: [ { start_datetime: { [Op.gt]: event.start_datetime } }, { start_datetime: event.start_datetime, id: { [Op.gt]: event.id } } ] }, order: [['start_datetime', 'ASC'], ['id', 'ASC']] }) const prev = await Event.findOne({ attributes: ['id', 'slug'], where: { is_visible: true, id: { [Op.not]: event.id }, recurrent: null, [Op.or]: [ { start_datetime: { [Op.lt]: event.start_datetime } }, { start_datetime: event.start_datetime, id: { [Op.lt]: event.id } } ] }, order: [['start_datetime', 'DESC'], ['id', 'DESC']] }) if (event && (event.is_visible || is_admin)) { event = event.get() event.next = next && (next.slug || next.id) event.prev = prev && (prev.slug || prev.id) event.tags = event.tags.map(t => t.tag) if (format === 'json') { res.json(event) } else if (format === 'ics') { // last arg is alarms/reminder, ref: https://github.com/adamgibbons/ics#attributes (alarms) exportController.ics(req, res, [event], [{ action: 'display', description: event.title, trigger: { hours: 1, before: true } }]) } } else { res.sendStatus(404) } }, /** confirm an anonymous event * and send related notifications */ async confirm(req, res) { const id = Number(req.params.event_id) const event = await Event.findByPk(id, { include: [Place, Tag] }) if (!event) { log.warn(`Trying to confirm a unknown event, id: ${id}`) return res.sendStatus(404) } if (!req.user.is_admin && req.user.id !== event.userId) { log.warn(`Someone not allowed is trying to confirm -> "${event.title} `) return res.sendStatus(403) } log.info(`Event "${event.title}" confirmed`) try { event.is_visible = true await event.save() res.sendStatus(200) // send notification notifier.notifyEvent('Create', event.id) } catch (e) { log.error('[EVENT]', e) res.sendStatus(404) } }, async unconfirm(req, res) { const id = Number(req.params.event_id) const event = await Event.findByPk(id) if (!event) { return req.sendStatus(404) } if (!req.user.is_admin && req.user.id !== event.userId) { log.warn(`Someone not allowed is trying to unconfirm -> "${event.title} `) return res.sendStatus(403) } try { await event.update({ is_visible: false }) res.sendStatus(200) } catch (e) { log.info(e) res.sendStatus(404) } }, /** get all unconfirmed events */ async getUnconfirmed(_req, res) { try { const events = await Event.findAll({ where: { parentId: null, is_visible: false, start_datetime: { [Op.gt]: dayjs().unix() } }, order: [['start_datetime', 'ASC']], include: [{ model: Tag, required: false }, Place] }) res.json(events) } catch (e) { log.info(e) res.sendStatus(400) } }, async addNotification(req, res) { try { const notification = { filters: { is_visible: true }, email: req.body.email, type: 'mail', remove_code: crypto.randomBytes(16).toString('hex') } await Notification.create(notification) res.sendStatus(200) } catch (e) { res.sendStatus(404) } }, async delNotification(req, res) { const remove_code = req.params.code try { const notification = await Notification.findOne({ where: { remove_code } }) await notification.destroy() } catch (e) { return res.sendStatus(404) } res.sendStatus(200) }, async isAnonEventAllowed(req, res, next) { if (!res.locals.settings.allow_anon_event && !req.user) { return res.sendStatus(403) } next() }, async add(req, res) { // req.err comes from multer streaming error if (req.err) { log.warn(req.err) return res.status(400).json(req.err.toString()) } try { const body = req.body const recurrent = body.recurrent ? JSON.parse(body.recurrent) : null const required_fields = ['title', 'start_datetime'] let missing_field = required_fields.find(required_field => !body[required_field]) if (missing_field) { log.warn(`${missing_field} required`) return res.status(400).send(`${missing_field} required`) } // find or create the place let place try { place = await eventController._findOrCreatePlace(body) if (!place) { return res.status(400).send(`Place not found`) } } catch (e) { log.error(e.message) return res.status(400).send(e.message) } const eventDetails = { title: body.title.trim(), // sanitize and linkify html description: helpers.sanitizeHTML(linkifyHtml(body.description || '')), multidate: body.multidate, start_datetime: body.start_datetime, end_datetime: body.end_datetime, recurrent, // publish this event only if authenticated is_visible: !!req.user } if (req.file || body.image_url) { if (!req.file && body.image_url) { req.file = await helpers.getImageFromURL(body.image_url) } let focalpoint = body.image_focalpoint ? body.image_focalpoint.split(',') : ['0', '0'] focalpoint = [parseFloat(parseFloat(focalpoint[0]).toFixed(2)), parseFloat(parseFloat(focalpoint[1]).toFixed(2))] eventDetails.media = [{ url: req.file.filename, height: req.file.height, width: req.file.width, name: body.image_name || body.title || '', size: req.file.size || 0, focalpoint }] } else { eventDetails.media = [] } let event = await Event.create(eventDetails) await event.setPlace(place) // create/assign tags let tags = [] if (body.tags) { tags = await tagController._findOrCreate(body.tags) await event.setTags(tags) } // associate user to event and reverse if (req.user) { await req.user.addEvent(event) await event.setUser(req.user) } event = event.get() event.tags = tags.map(t => t.tag) event.place = place // return created event to the client res.json(event) // create recurrent instances of event if needed // without waiting for the task manager if (event.recurrent) { eventController._createRecurrent() } else { // send notifications const notifier = require('../../notifier') notifier.notifyEvent('Create', event.id) } } catch (e) { log.error('[EVENT ADD]', e) res.sendStatus(400) } }, async update(req, res) { if (res.err) { log.warn(req.err) return res.status(400).json(req.err.toString()) } try { const body = req.body const event = await Event.findByPk(body.id) if (!event) { return res.sendStatus(404) } if (!req.user.is_admin && event.userId !== req.user.id) { return res.sendStatus(403) } const recurrent = body.recurrent ? JSON.parse(body.recurrent) : null const eventDetails = { title: body.title || event.title, // sanitize and linkify html description: helpers.sanitizeHTML(linkifyHtml(body.description || '', { target: '_blank' })) || event.description, multidate: body.multidate, start_datetime: body.start_datetime || event.start_datetime, end_datetime: body.end_datetime || null, recurrent } // remove old media in case a new one is uploaded if ((req.file || /^https?:\/\//.test(body.image_url)) && !event.recurrent && event.media && event.media.length) { try { const old_path = path.resolve(config.upload_path, event.media[0].url) const old_thumb_path = path.resolve(config.upload_path, 'thumb', event.media[0].url) fs.unlinkSync(old_path) fs.unlinkSync(old_thumb_path) } catch (e) { log.info(e.toString()) } } // modify associated media only if a new file is uploaded or remote image_url is used if (req.file || (body.image_url && /^https?:\/\//.test(body.image_url))) { if (!req.file && body.image_url) { req.file = await helpers.getImageFromURL(body.image_url) } let focalpoint = body.image_focalpoint ? body.image_focalpoint.split(',') : ['0', '0'] focalpoint = [parseFloat(parseFloat(focalpoint[0]).toFixed(2)), parseFloat(parseFloat(focalpoint[1]).toFixed(2))] eventDetails.media = [{ url: req.file.filename, height: req.file.height, width: req.file.width, name: body.image_name || body.title || '', size: req.file.size || 0, focalpoint }] } else if (!body.image) { eventDetails.media = [] } else if (body.image_focalpoint && event.media.length) { let focalpoint = body.image_focalpoint ? body.image_focalpoint.split(',') : ['0', '0'] focalpoint = [parseFloat(parseFloat(focalpoint[0]).toFixed(2)), parseFloat(parseFloat(focalpoint[1]).toFixed(2))] eventDetails.media = [{ ...event.media[0], focalpoint }] // [0].focalpoint = focalpoint } await event.update(eventDetails) // find or create the place let place try { place = await eventController._findOrCreatePlace(body) if (!place) { return res.status(400).send(`Place not found`) } } catch (e) { return res.status(400).send(e.message) } await event.setPlace(place) // create/assign tags let tags = [] if (body.tags) { tags = await tagController._findOrCreate(body.tags) } await event.setTags(tags) let newEvent = await Event.findByPk(event.id, { include: [Tag, Place] }) newEvent = newEvent.get() newEvent.tags = tags.map(t => t.tag) newEvent.place = place res.json(newEvent) // create recurrent instances of event if needed // without waiting for the task manager if (event.recurrent) { eventController._createRecurrent() } else { const notifier = require('../../notifier') notifier.notifyEvent('Update', event.id) } } catch (e) { log.error('[EVENT UPDATE]', e) res.sendStatus(400) } }, async remove(req, res) { const event = await Event.findByPk(req.params.id) // check if event is mine (or user is admin) if (event && (req.user.is_admin || req.user.id === event.userId)) { if (event.media && event.media.length && !event.recurrent) { try { const old_path = path.join(config.upload_path, event.media[0].url) const old_thumb_path = path.join(config.upload_path, 'thumb', event.media[0].url) fs.unlinkSync(old_thumb_path) fs.unlinkSync(old_path) } catch (e) { log.info(e.toString()) } } const notifier = require('../../notifier') await notifier.notifyEvent('Delete', event.id) // unassociate child events if (event.recurrent) { await Event.update({ parentId: null }, { where: { parentId: event.id } }) } log.debug('[EVENT REMOVED] ' + event.title) await event.destroy() res.sendStatus(200) } else { res.sendStatus(403) } }, /** * Method to search for events with pagination and filtering * @returns */ async _select({ start = dayjs().unix(), end, query, tags, places, show_recurrent, show_multidate, limit, page, older }) { const where = { // do not include _parent_ recurrent event recurrent: null, // confirmed event only is_visible: true, [Op.or]: { start_datetime: { [older ? Op.lte : Op.gte]: start }, end_datetime: { [older ? Op.lte : Op.gte]: start } } } // include recurrent events? if (!show_recurrent) { where.parentId = null } if (!show_multidate) { where.multidate = { [Op.not]: true } } if (end) { where.start_datetime = { [older ? Op.gte : Op.lte]: end } } // normalize tags if (tags) { tags = tags.split(',').map(t => t.trim().toLocaleLowerCase()) } const replacements = [] if (tags && places) { where[Op.and] = [ { placeId: places ? places.split(',') : [] }, Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) in (?)`)) ] replacements.push(tags) } else if (tags) { where[Op.and] = Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) in (?)`)) replacements.push(tags) } else if (places) { where.placeId = places.split(',') } if (query) { replacements.push(query) where[Op.or] = [ { title: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('title')), 'LIKE', '%' + query + '%') }, Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + query + '%'), Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) = ?`)) ] } let pagination = {} if (limit) { pagination = { limit, offset: limit * page, } } const events = await Event.findAll({ where, attributes: { exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'description', 'resources', 'recurrent', 'placeId', 'image_path'] }, order: [['start_datetime', older ? 'DESC' : 'ASC']], include: [ { model: Tag, // order: [Sequelize.literal('(SELECT COUNT(tagTag) FROM event_tags WHERE tagTag = tag) DESC')], attributes: ['tag'], through: { attributes: [] } }, { model: Place, required: true, attributes: ['id', 'name', 'address', 'latitude', 'longitude'] } ], ...pagination, replacements }).catch(e => { log.error('[EVENT]' + String(e)) return [] }) return events.map(e => { e = e.get() e.tags = e.tags ? e.tags.map(t => t && t.tag) : [] return e }) }, /** * Select events based on params */ async select(req, res) { const settings = res.locals.settings const start = req.query.start || dayjs().unix() const end = req.query.end const query = req.query.query const tags = req.query.tags const places = req.query.places const limit = Number(req.query.max) || 0 const page = Number(req.query.page) || 0 const older = req.query.older || false const show_multidate = settings.allow_multidate_event && typeof req.query.show_multidate !== 'undefined' ? req.query.show_multidate !== 'false' : true const show_recurrent = settings.allow_recurrent_event && typeof req.query.show_recurrent !== 'undefined' ? req.query.show_recurrent === 'true' : settings.recurrent_event_visible res.json(await eventController._select({ start, end, query, places, tags, show_recurrent, show_multidate, limit, page, older })) }, /** * Ensure we have the next instance of a recurrent event */ async _createRecurrentOccurrence(e, startAt) { log.debug(`Create recurrent event [${e.id}] ${e.title}"`) const event = { parentId: e.id, title: e.title, description: e.description, media: e.media, is_visible: true, userId: e.userId, placeId: e.placeId } const recurrent = e.recurrent const start_date = dayjs.unix(e.start_datetime) let cursor = start_date > startAt ? start_date : startAt startAt = cursor const duration = dayjs.unix(e.end_datetime).diff(start_date, 's') const frequency = recurrent.frequency const type = recurrent.type cursor = cursor.hour(start_date.hour()).minute(start_date.minute()).second(0) if (!frequency) { return } // each week or 2 if (frequency[1] === 'w') { cursor = cursor.day(start_date.day()) if (cursor.isBefore(startAt)) { cursor = cursor.add(7, 'day') } if (frequency[0] === '2') { cursor = cursor.add(7, 'day') } } else if (frequency === '1m') { if (type === 'ordinal') { cursor = cursor.date(start_date.date()) if (cursor.isBefore(startAt)) { cursor = cursor.add(1, 'month') } } else { // weekday // get weekday // get recurrent freq details cursor = helpers.getWeekdayN(cursor, type, start_date.day()) if (cursor.isBefore(startAt)) { cursor = cursor.add(4, 'week') cursor = helpers.getWeekdayN(cursor, type, start_date.day()) } } } log.debug(cursor) event.start_datetime = cursor.unix() event.end_datetime = event.start_datetime + duration const newEvent = await Event.create(event) return newEvent.addTags(e.tags) }, /** * Create instances of recurrent events */ async _createRecurrent(start_datetime = dayjs().unix()) { // select recurrent events and its childs const events = await Event.findAll({ where: { is_visible: true, recurrent: { [Op.ne]: null } }, include: [{ model: Tag, required: false }, { model: Event, as: 'child', required: false, where: { start_datetime: { [Op.gte]: start_datetime } } }], order: [['child', 'start_datetime', 'DESC']] }) // create a new occurrence for each recurring events but the one's that has an already visible occurrence coming const creations = events.map(e => { if (e.child.length) { if (e.child.find(c => c.is_visible)) return return eventController._createRecurrentOccurrence(e, dayjs.unix(e.child[0].start_datetime + 1)) } return eventController._createRecurrentOccurrence(e, dayjs()) }) return Promise.all(creations) } } module.exports = eventController