tag / place / event filtering, refactoring, cleaning

This commit is contained in:
lesion 2022-06-18 01:14:26 +02:00
parent 4d39fc41c5
commit e03b9c4499
No known key found for this signature in database
GPG key ID: 352918250B012177
7 changed files with 164 additions and 84 deletions

View file

@ -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
}))
},

View file

@ -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 })
},
/**

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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')
})