diff --git a/server/api/controller/event.js b/server/api/controller/event.js index 3464747b..285369cf 100644 --- a/server/api/controller/event.js +++ b/server/api/controller/event.js @@ -8,7 +8,7 @@ const linkifyHtml = require('linkify-html') const Sequelize = require('sequelize') const dayjs = require('dayjs') const helpers = require('../../helpers') - +const Col = helpers.col const Event = require('../models/event') const Resource = require('../models/resource') const Tag = require('../models/tag') @@ -17,6 +17,7 @@ const Notification = require('../models/notification') const APUser = require('../models/ap_user') const exportController = require('./export') +const tagController = require('./tag') const log = require('../../log') @@ -29,8 +30,8 @@ const eventController = { order: [[Sequelize.col('w'), 'DESC']], where: { [Op.or]: [ - { name: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + search + '%' )}, - { address: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('address')), 'LIKE', '%' + search + '%')}, + Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), 'LIKE', '%' + search + '%' ), + Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('address')), 'LIKE', '%' + search + '%') ] }, attributes: [['name', 'label'], 'address', 'id', [Sequelize.cast(Sequelize.fn('COUNT', Sequelize.col('events.placeId')),'INTEGER'), 'w']], @@ -91,7 +92,7 @@ const eventController = { [ { 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 "event_tags"."eventId"="event".id AND "tagTag" = ?')) + Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) = ?`)) ] } @@ -105,7 +106,7 @@ const eventController = { include: [ { model: Tag, - order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')], + // order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')], attributes: ['tag'], through: { attributes: [] } }, @@ -247,7 +248,6 @@ const eventController = { order: [['start_datetime', 'DESC'], ['id', 'DESC']] }) - // TODO: also check if event is mine if (event && (event.is_visible || is_admin)) { event = event.get() event.next = next && (next.slug || next.id) @@ -278,7 +278,7 @@ const eventController = { return res.sendStatus(404) } if (!res.locals.user.is_admin && res.locals.user.id !== event.userId) { - log.warn(`Someone unallowed is trying to confirm -> "${event.title} `) + log.warn(`Someone not allowed is trying to confirm -> "${event.title} `) return res.sendStatus(403) } @@ -304,6 +304,7 @@ const eventController = { const event = await Event.findByPk(id) if (!event) { return req.sendStatus(404) } if (!res.locals.user.is_admin && res.locals.user.id !== event.userId) { + log.warn(`Someone not allowed is trying to unconfirm -> "${event.title} `) return res.sendStatus(403) } @@ -317,7 +318,7 @@ const eventController = { }, /** get all unconfirmed events */ - async getUnconfirmed (req, res) { + async getUnconfirmed (_req, res) { try { const events = await Event.findAll({ where: { @@ -391,7 +392,7 @@ const eventController = { if (body.place_id) { place = await Place.findByPk(body.place_id) } else { - place = await Place.findOne({ where: { name: body.place_name.trim() }}) + place = await Place.findOne({ where: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), Op.eq, body.place_name.trim().toLocaleLowerCase() )}) if (!place) { if (!body.place_address || !body.place_name) { return res.status(400).send(`place_id or place_name and place_address required`) @@ -427,6 +428,7 @@ const eventController = { height: req.file.height, width: req.file.width, name: body.image_name || body.title || '', + size: req.file.size || 0, focalpoint: [parseFloat(focalpoint[0]), parseFloat(focalpoint[1])] }] } else { @@ -438,11 +440,10 @@ const eventController = { await event.setPlace(place) // create/assign tags + let tags = [] if (body.tags) { - body.tags = body.tags.map(t => t.trim()) - await Tag.bulkCreate(body.tags.map(t => ({ tag: t })), { ignoreDuplicates: true }) - const tags = await Tag.findAll({ where: { tag: { [Op.in]: body.tags } } }) - await event.addTags(tags) + tags = await tagController._findOrCreate(body.tags) + await event.setTags(tags) } // associate user to event and reverse @@ -452,7 +453,7 @@ const eventController = { } event = event.get() - event.tags = body.tags + event.tags = tags.map(t => t.tag) event.place = place // return created event to the client res.json(event) @@ -520,6 +521,7 @@ const eventController = { height: req.file.height, width: req.file.width, name: body.image_name || body.title || '', + size: req.file.size || 0, focalpoint: [parseFloat(focalpoint[0].slice(0, 6)), parseFloat(focalpoint[1].slice(0, 6))] }] } else if (!body.image) { @@ -584,58 +586,85 @@ const eventController = { } }, - async _select ({ start, end, tags, places, show_recurrent, max }) { + /** + * Method to search for events with pagination and filtering + * @returns + */ + async _select ({ + start = dayjs().unix(), + end, + tags, + places, + show_recurrent, + limit, + page, + older }) { const where = { - // do not include parent recurrent event + // do not include _parent_ recurrent event recurrent: null, // confirmed event only is_visible: true, [Op.or]: { - start_datetime: { [Op.gte]: start }, - end_datetime: { [Op.gte]: start } + 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 (end) { - where.start_datetime = { [Op.lte]: 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.or] = { - placeId: places ? places.split(',') : [], - } + where[Op.and] = [ + { placeId: places ? places.split(',') : []}, + Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=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 "event_tags"."eventId"="event".id AND "tagTag" in (?)')) + where[Op.and] = Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=event.id AND LOWER(${Col('tagTag')}) in (?)`)) replacements.push(tags) } else if (places) { where.placeId = places.split(',') } + let pagination = {} + if (limit) { + pagination = { + limit, + offset: limit * page, + } + } + const events = await Event.findAll({ where, attributes: { - exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'updatedAt', 'description', 'resources'] + exclude: ['likes', 'boost', 'userId', 'is_visible', 'createdAt', 'description', 'resources', 'recurrent', 'placeId', 'parentId'] }, - order: ['start_datetime'], + order: [['start_datetime', older ? 'DESC' : 'ASC' ]], include: [ - { model: Resource, required: false, attributes: ['id'] }, { model: Tag, - order: [Sequelize.literal('(SELECT COUNT("tagTag") FROM event_tags WHERE tagTag = tag) DESC')], + // 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'] } ], - limit: max, + ...pagination, replacements }).catch(e => { log.error('[EVENT]', e) @@ -658,13 +687,15 @@ const eventController = { const end = req.query.end const tags = req.query.tags const places = req.query.places - const max = req.query.max + const limit = req.query.max + const page = req.query.page = 0 + const older = req.query.older || false 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, places, tags, show_recurrent, max + start, end, places, tags, show_recurrent, limit, page, older })) }, diff --git a/server/api/controller/export.js b/server/api/controller/export.js index 765aee5c..176a6f6f 100644 --- a/server/api/controller/export.js +++ b/server/api/controller/export.js @@ -10,7 +10,7 @@ const ics = require('ics') const exportController = { async export (req, res) { - const type = req.params.type + const format = req.params.format const tags = req.query.tags const places = req.query.places const show_recurrent = !!req.query.show_recurrent @@ -43,7 +43,7 @@ const exportController = { attributes: { exclude: ['is_visible', 'recurrent', 'createdAt', 'likes', 'boost', 'userId', 'placeId'] }, where: { is_visible: true, - recurrent: { [Op.eq]: null }, + recurrent: null, start_datetime: { [Op.gte]: yesterday }, ...where }, @@ -58,7 +58,7 @@ const exportController = { { model: Place, attributes: ['name', 'id', 'address'] }] }) - switch (type) { + switch (format) { case 'rss': case 'feed': return exportController.feed(req, res, events.slice(0, 20)) @@ -69,10 +69,10 @@ const exportController = { } }, - feed (_req, res, events) { + feed (_req, res, events, title = res.locals.settings.title, link = `${res.locals.settings.baseurl}/feed/rss`) { const settings = res.locals.settings res.type('application/rss+xml; charset=UTF-8') - res.render('feed/rss.pug', { events, settings, moment }) + res.render('feed/rss.pug', { events, settings, moment, title, link }) }, /** diff --git a/server/api/controller/place.js b/server/api/controller/place.js index 9da6068f..1c9b32ae 100644 --- a/server/api/controller/place.js +++ b/server/api/controller/place.js @@ -1,22 +1,36 @@ -const dayjs = require('dayjs') const Place = require('../models/place') const Event = require('../models/event') const eventController = require('./event') +const exportController = require('./export') + const log = require('../../log') const { Op, where, col, fn, cast } = require('sequelize') module.exports = { + async getEvents (req, res) { - const name = req.params.placeName - const place = await Place.findOne({ where: { name }}) + const placeName = req.params.placeName + const place = await Place.findOne({ where: { name: placeName }}) if (!place) { - log.warn(`Place ${name} not found`) + log.warn(`Place ${placeName} not found`) return res.sendStatus(404) } - const start = dayjs().unix() - const events = await eventController._select({ start, places: `${place.id}`, show_recurrent: true}) - return res.json({ events, place }) + const format = req.params.format || 'json' + log.debug(`Events for place: ${placeName}`) + const events = await eventController._select({ places: String(place.id), show_recurrent: true }) + + switch (format) { + case 'rss': + return exportController.feed(req, res, events, + `${res.locals.settings.title} - Place @${place.name}`, + `${res.locals.settings.baseurl}/feed/rss/place/${place.name}`) + case 'ics': + return exportController.ics(req, res, events) + default: + return res.json({ events, place }) + } + }, @@ -36,7 +50,7 @@ module.exports = { return res.json(places) }, - async get (req, res) { + async search (req, res) { const search = req.query.search.toLocaleLowerCase() const places = await Place.findAll({ order: [[cast(fn('COUNT', col('events.placeId')),'INTEGER'), 'DESC']], @@ -49,7 +63,9 @@ module.exports = { attributes: ['name', 'address', 'id'], include: [{ model: Event, where: { is_visible: true }, required: true, attributes: [] }], group: ['place.id'], - raw: true + raw: true, + limit: 10, + subQuery: false }) // TOFIX: don't know why limit does not work diff --git a/server/api/controller/tag.js b/server/api/controller/tag.js index 10e7fc8c..b8574d69 100644 --- a/server/api/controller/tag.js +++ b/server/api/controller/tag.js @@ -1,31 +1,37 @@ const Tag = require('../models/tag') const Event = require('../models/event') + const { where, fn, col, Op } = require('sequelize') const exportController = require('./export') -const eventController = require('./event') module.exports = { - // async getEvents (req, res) { - // const name = req.params.placeName - // const place = await Place.findOne({ where: { name }}) - // if (!place) { - // log.warn(`Place ${name} not found`) - // return res.sendStatus(404) - // } - // const start = dayjs().unix() - // const events = await eventController._select({ start, places: `${place.id}`, show_recurrent: true}) - // return res.json({ events, place }) - // }, + async _findOrCreate (tags) { + // trim tags + const trimmedTags = tags.map(t => t.trim()) + const lowercaseTags = trimmedTags.map(t => t.toLocaleLowerCase()) - // /feed/rss/tag/tagname - // /feed/ics/tag/tagname - // /feed/json/tag/tagname + // search for already existing tags (tag is the same as TaG) + const existingTags = await Tag.findAll({ where: { [Op.and]: where(fn('LOWER', col('tag')), { [Op.in]: lowercaseTags }) } }) + const lowercaseExistingTags = existingTags.map(t => t.tag.toLocaleLowerCase()) + const remainingTags = trimmedTags.filter(t => ! lowercaseExistingTags.includes(t.toLocaleLowerCase())) + + // create remaining tags (cannot use updateOnDuplicate or manage conflicts) + return [].concat( + existingTags, + await Tag.bulkCreate(remainingTags.map(t => ({ tag: t }))) + ) + }, + + // /feed/rss/tag/:tagname + // /feed/ics/tag/:tagname + // /feed/json/tag/:tagname + // tag/:tag async getEvents (req, res) { + const eventController = require('./event') const format = req.params.format || 'json' const tags = req.params.tag - const events = await eventController._select({ tags, show_recurrent: true }) - + const events = await eventController._select({ tags: tags.toLocaleLowerCase(), show_recurrent: true }) switch (format) { case 'rss': return exportController.feed(req, res, events, diff --git a/server/api/index.js b/server/api/index.js index b0e064ca..3ecbaa12 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -33,7 +33,7 @@ if (config.status !== 'READY') { const resourceController = require('./controller/resource') const oauthController = require('./controller/oauth') const announceController = require('./controller/announce') - const cohortController = require('./controller/cohort') + const collectionController = require('./controller/collection') const helpers = require('../helpers') const storage = require('./storage') const upload = multer({ storage }) @@ -58,8 +58,8 @@ if (config.status !== 'READY') { } ``` */ - api.get('/ping', (req, res) => res.sendStatus(200)) - api.get('/user', isAuth, (req, res) => res.json(res.locals.user)) + api.get('/ping', (_req, res) => res.sendStatus(200)) + api.get('/user', isAuth, (_req, res) => res.json(res.locals.user)) api.post('/user/recover', userController.forgotPassword) @@ -88,9 +88,11 @@ if (config.status !== 'READY') { * @param {integer} [start] - start timestamp (default: now) * @param {integer} [end] - end timestamp (optional) * @param {array} [tags] - List of tags - * @param {array} [places] - List of places - * @param {integer} [max] - Max events + * @param {array} [places] - List of places id + * @param {integer} [max] - Limit events * @param {boolean} [show_recurrent] - Show also recurrent events (default: as choosen in admin settings) + * @param {integer} [page] - Pagination + * @param {boolean} [older] - select <= start instead of >= * @example ***Example*** * [https://demo.gancio.org/api/events](https://demo.gancio.org/api/events) * [usage example](https://framagit.org/les/gancio/-/blob/master/webcomponents/src/GancioEvents.svelte#L18-42) @@ -131,9 +133,6 @@ if (config.status !== 'READY') { // get tags/places api.get('/event/meta', eventController.searchMeta) - // get unconfirmed events - api.get('/event/unconfirmed', isAdmin, eventController.getUnconfirmed) - // add event notification TODO api.post('/event/notification', eventController.addNotification) api.delete('/event/notification/:code', eventController.delNotification) @@ -142,7 +141,10 @@ if (config.status !== 'READY') { api.post('/settings/logo', isAdmin, multer({ dest: config.upload_path }).single('logo'), settingsController.setLogo) api.post('/settings/smtp', isAdmin, settingsController.testSMTP) - // confirm event + // get unconfirmed events + api.get('/event/unconfirmed', isAdmin, eventController.getUnconfirmed) + + // [un]confirm event api.put('/event/confirm/:event_id', isAuth, eventController.confirm) api.put('/event/unconfirm/:event_id', isAuth, eventController.unconfirm) @@ -153,12 +155,13 @@ if (config.status !== 'READY') { api.get('/export/:type', cors, exportController.export) - api.get('/place/:placeName/events', cors, placeController.getEvents) api.get('/place/all', isAdmin, placeController.getAll) - api.get('/place', cors, placeController.get) + api.get('/place/:placeName', cors, placeController.getEvents) + api.get('/place', cors, placeController.search) api.put('/place', isAdmin, placeController.updatePlace) - api.get('/tag', cors, tagController.get) + api.get('/tag', cors, tagController.search) + api.get('/tag/:tag', cors, tagController.getEvents) // - FEDIVERSE INSTANCES, MODERATION, RESOURCES api.get('/instances', isAdmin, instanceController.getAll) @@ -175,14 +178,14 @@ if (config.status !== 'READY') { api.put('/announcements/:announce_id', isAdmin, announceController.update) api.delete('/announcements/:announce_id', isAdmin, announceController.remove) - // - COHORT - api.get('/cohorts/:name', cohortController.getEvents) - api.get('/cohorts', cohortController.getAll) - api.post('/cohorts', isAdmin, cohortController.add) - api.delete('/cohort/:id', isAdmin, cohortController.remove) - api.get('/filter/:cohort_id', isAdmin, cohortController.getFilters) - api.post('/filter', isAdmin, cohortController.addFilter) - api.delete('/filter/:id', isAdmin, cohortController.removeFilter) + // - COLLECTIONS + api.get('/collections/:name', cors, collectionController.getEvents) + api.get('/collections', collectionController.getAll) + api.post('/collections', isAdmin, collectionController.add) + api.delete('/collection/:id', isAdmin, collectionController.remove) + api.get('/filter/:collection_id', isAdmin, collectionController.getFilters) + api.post('/filter', isAdmin, collectionController.addFilter) + api.delete('/filter/:id', isAdmin, collectionController.removeFilter) // OAUTH api.get('/clients', isAuth, oauthController.getClients) diff --git a/server/helpers.js b/server/helpers.js index 0d7e6a90..57cf6372 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -116,6 +116,14 @@ module.exports = { next() }, + col (field) { + if (config.db.dialect === 'postgres') { + return '"' + field.split('.').join('"."') + '"' + } else { + return field + } + }, + async getImageFromURL (url) { log.debug(`getImageFromURL ${url}`) if(!/^https?:\/\//.test(url)) { @@ -233,5 +241,13 @@ module.exports = { } } next() + }, + + async feedRedirect (req, res, next) { + const accepted = req.accepts('html', 'application/rss+xml', 'text/calendar') + if (['application/rss+xml', 'text/calendar'].includes(accepted) && /^\/(tag|place|collection)\/.*/.test(req.path)) { + return res.redirect((accepted === 'application/rss+xml' ? '/feed/rss' : '/feed/ics') + req.path) + } + next() } } diff --git a/server/routes.js b/server/routes.js index 5175c57a..f92d83a2 100644 --- a/server/routes.js +++ b/server/routes.js @@ -34,9 +34,17 @@ if (config.status === 'READY') { const federation = require('./federation') const webfinger = require('./federation/webfinger') const exportController = require('./api/controller/export') + const tagController = require('./api/controller/tag') + const placeController = require('./api/controller/place') + const collectionController = require('./api/controller/collection') + + // rss / ics feed + app.use(helpers.feedRedirect) + app.get('/feed/:format/tag/:tag', cors(), tagController.getEvents) + app.get('/feed/:format/place/:placeName', cors(), placeController.getEvents) + app.get('/feed/:format/collection/:name', cors(), collectionController.getEvents) + app.get('/feed/:format', cors(), exportController.export) - // rss/ics/atom feed - app.get('/feed/:type', cors(), exportController.export) app.use('/event/:slug', helpers.APRedirect) @@ -59,7 +67,7 @@ app.use('/api', api) // // Handle 500 app.use((error, _req, res, _next) => { - log.error('[ERROR]', error) + log.error('[ERROR]' + error) return res.status(500).send('500: Internal Server Error') })