import { HttpStatusCode } from '@peertube/peertube-models' import express from 'express' import { auditLoggerFactory, getAuditIdFromRes, EventAuditView } from '@server/helpers/audit-logger.js' import { getFormattedObjects } from '@server/helpers/utils.js' import { createHash } from 'crypto' import { apiRateLimiter, asyncMiddleware, authenticate, paginationValidator, setDefaultPagination } from '@server/middlewares/index.js' import { openapiOperationDoc } from '@server/middlewares/doc.js' import { createEventValidator, updateEventValidator, deleteEventValidator, updateEventStateValidator, getEventValidator } from '@server/middlewares/validators/events.js' import { createEventRegistrationValidator, deleteEventRegistrationValidator, getEventRegistrationValidator, listEventRegistrationsValidator, updateEventRegistrationValidator } from '@server/middlewares/validators/event-registrations.js' import { EventRegistrationModel } from '@server/models/video/event-registration.js' import { EventModel, EventState } from '@server/models/video/event.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' import { sequelizeTypescript } from '@server/initializers/database.js' import { createReqFiles } from '@server/helpers/express-utils.js' import { MIMETYPES, THUMBNAILS_SIZE } from '@server/initializers/constants.js' import { CONFIG } from '@server/initializers/config.js' import { processImageFromWorker } from '@server/lib/worker/parent-process.js' import { join } from 'path' import { ensureDir, remove } from 'fs-extra' import { readFile } from 'fs/promises' import { generateImageFilename } from '@server/helpers/image-utils.js' const auditLogger = auditLoggerFactory('events') const eventsRouter = express.Router() const SPECIAL_THUMBNAILS_TMP_DIR = join(CONFIG.STORAGE.TMP_DIR, 'special-thumbnails') const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) eventsRouter.use(apiRateLimiter) // List public events eventsRouter.get( '/', openapiOperationDoc({ operationId: 'getEvents' }), paginationValidator, setDefaultPagination, asyncMiddleware(listPublicEvents) ) eventsRouter.get( '/registrations', openapiOperationDoc({ operationId: 'getAllEventRegistrations' }), authenticate, paginationValidator, setDefaultPagination, asyncMiddleware(listAllEventRegistrations) ) // Get event by ID (public) eventsRouter.get( '/:id', openapiOperationDoc({ operationId: 'getEvent' }), getEventValidator, asyncMiddleware(getEvent) ) eventsRouter.get( '/:eventId/registrations', openapiOperationDoc({ operationId: 'getEventRegistrations' }), authenticate, paginationValidator, setDefaultPagination, listEventRegistrationsValidator, asyncMiddleware(listEventRegistrations) ) eventsRouter.get( '/:eventId/registrations/:registrationId', openapiOperationDoc({ operationId: 'getEventRegistration' }), authenticate, getEventRegistrationValidator, asyncMiddleware(getEventRegistration) ) eventsRouter.post( '/:eventId/registrations', openapiOperationDoc({ operationId: 'createEventRegistration' }), createEventRegistrationValidator, asyncMiddleware(createEventRegistration) ) eventsRouter.put( '/:eventId/registrations/:registrationId', openapiOperationDoc({ operationId: 'updateEventRegistration' }), authenticate, updateEventRegistrationValidator, asyncMiddleware(updateEventRegistration) ) eventsRouter.delete( '/:eventId/registrations/:registrationId', openapiOperationDoc({ operationId: 'deleteEventRegistration' }), authenticate, deleteEventRegistrationValidator, asyncMiddleware(deleteEventRegistration) ) // Create event (admin/channel owner only) eventsRouter.post( '/:channelId/events', openapiOperationDoc({ operationId: 'createEvent' }), authenticate, reqThumbnailFile, createEventValidator, asyncMiddleware(createEvent) ) // Update event (admin/channel owner only) eventsRouter.put( '/:channelId/events/:eventId', openapiOperationDoc({ operationId: 'updateEvent' }), authenticate, reqThumbnailFile, updateEventValidator, asyncMiddleware(updateEvent) ) // Delete event (admin/channel owner only) eventsRouter.delete( '/:channelId/events/:eventId', openapiOperationDoc({ operationId: 'deleteEvent' }), authenticate, deleteEventValidator, asyncMiddleware(deleteEvent) ) // Change event state (admin/channel owner only) eventsRouter.patch( '/:channelId/events/:eventId/state', openapiOperationDoc({ operationId: 'updateEventState' }), authenticate, updateEventStateValidator, asyncMiddleware(updateEventState) ) // List channel events (admin/channel owner only) eventsRouter.get( '/:channelId/events', openapiOperationDoc({ operationId: 'getChannelEvents' }), authenticate, asyncMiddleware(listChannelEvents) ) async function listPublicEvents (req: express.Request, res: express.Response) { const state = req.query.state as string | undefined const states = state ? state.split(',').filter(s => s) : undefined const events = await EventModel.listPublicEvents(undefined, states) const count = events.length return res.json(getFormattedObjects(events, count)) } async function getEvent (req: express.Request, res: express.Response) { const event = (res.locals as any).event return res.json(event.toFormattedJSON()) } async function listEventRegistrations (req: express.Request, res: express.Response) { const event = (res.locals as any).event if (!isEventOwnerOrAdmin(res, event)) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this event' }) } const { count, rows } = await EventRegistrationModel.listForApi({ eventId: event.id, start: req.query.start as number, count: req.query.count as number }) return res.json(getFormattedObjects(rows, count)) } async function listAllEventRegistrations (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User // Only admin allowed (or extend if needed) if (user.role !== 0) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Only admin can view all event registrations' }) } const { count, rows } = await EventRegistrationModel.findAndCountAll({ include: [ { model: EventModel, attributes: ['id', 'title', 'startTime', 'endTime', 'thumbnailFilename'] } ], offset: req.query.start as number, limit: req.query.count as number, order: [['createdAt', 'DESC']] }) const data = rows.map((row: any) => { const json = row.toJSON() return { ...json, event: json.Event ? { id: json.Event.id, title: json.Event.title, startTime: json.Event.startTime, endTime: json.Event.endTime, thumbnailFilename: json.Event.thumbnailFilename } : null } }) return res.json({ total: count, data }) } async function getEventRegistration (req: express.Request, res: express.Response) { const event = (res.locals as any).event const registration = (res.locals as any).eventRegistration if (!isEventOwnerOrAdmin(res, event)) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this event' }) } return res.json(registration.toFormattedJSON()) } async function listChannelEvents (req: express.Request, res: express.Response) { const { channelId } = req.params const channel = await VideoChannelModel.scope([ 'WITH_ACCOUNT' ]).findByPk(channelId) if (!channel) { return res.fail({ status: HttpStatusCode.NOT_FOUND_404, message: 'Channel not found' }) } // Check if user is channel owner or admin const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id if (!isChannelOwner && res.locals.oauth.token.User.role !== 0) { // 0 = admin return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this channel' }) } const events = await EventModel.listByChannelId(parseInt(channelId)) const count = events.length return res.json(getFormattedObjects(events, count)) } async function createEvent (req: express.Request, res: express.Response) { const { channelId } = req.params const { title, description, startTime, endTime, state, liveUrl } = req.body const channel = (res.locals as any).channel const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined const thumbnailFile = files?.thumbnailfile?.[0] // Check if user is channel owner or admin const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this channel' }) } const event = await sequelizeTypescript.transaction(async t => { let thumbnail: { filename: string, data: Buffer, size: number, mimeType: string, etag: string } | null = null if (thumbnailFile) { thumbnail = await saveEventThumbnailToTemporaryFile(thumbnailFile.path) } const created = await EventModel.create( { title, description: description || null, startTime: new Date(startTime), endTime: new Date(endTime), state: state || EventState.ACTIVE, liveUrl: liveUrl || null, channelId: parseInt(channelId), thumbnailFilename: thumbnail?.filename || null, thumbnailData: thumbnail?.data || null, thumbnailMimeType: thumbnail?.mimeType || null, thumbnailSize: thumbnail?.size || null, thumbnailEtag: thumbnail?.etag || null }, { transaction: t } ) return created }) auditLogger.create(getAuditIdFromRes(res), new EventAuditView(event)) return res.status(HttpStatusCode.CREATED_201).json(event.toFormattedJSON()) } async function updateEvent (req: express.Request, res: express.Response) { const { title, description, startTime, endTime, state, liveUrl } = req.body const event = (res.locals as any).event const channel = (res.locals as any).channel const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined const thumbnailFile = files?.thumbnailfile?.[0] const oldEventAuditView = new EventAuditView(event.toFormattedJSON()) // Check if user is channel owner or admin const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this channel' }) } const updatedEvent = await sequelizeTypescript.transaction(async t => { let thumbnailFilename = event.thumbnailFilename if (thumbnailFile) { const thumbnail = await saveEventThumbnailToTemporaryFile(thumbnailFile.path) thumbnailFilename = thumbnail.filename Object.assign(event, { thumbnailData: thumbnail.data, thumbnailMimeType: thumbnail.mimeType, thumbnailSize: thumbnail.size, thumbnailEtag: thumbnail.etag }) } await event.update( { title: title || event.title, description: description !== undefined ? description : event.description, startTime: startTime ? new Date(startTime) : event.startTime, endTime: endTime ? new Date(endTime) : event.endTime, state: state || event.state, liveUrl: liveUrl !== undefined ? liveUrl : event.liveUrl, thumbnailFilename, thumbnailData: event.thumbnailData, thumbnailMimeType: event.thumbnailMimeType, thumbnailSize: event.thumbnailSize, thumbnailEtag: event.thumbnailEtag }, { transaction: t } ) return event }) auditLogger.update(getAuditIdFromRes(res), new EventAuditView(updatedEvent.toFormattedJSON()), oldEventAuditView) return res.json(updatedEvent.toFormattedJSON()) } async function deleteEvent (req: express.Request, res: express.Response) { const event = (res.locals as any).event const channel = (res.locals as any).channel // Check if user is channel owner or admin const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this channel' }) } await sequelizeTypescript.transaction(async t => { await event.destroy({ transaction: t }) }) auditLogger.delete(getAuditIdFromRes(res), new EventAuditView(event.toFormattedJSON())) return res.sendStatus(HttpStatusCode.NO_CONTENT_204) } async function saveEventThumbnailToTemporaryFile (sourcePath: string) { const thumbnailFilename = generateImageFilename('.jpg') const destination = join(SPECIAL_THUMBNAILS_TMP_DIR, thumbnailFilename) await ensureDir(SPECIAL_THUMBNAILS_TMP_DIR) await processImageFromWorker({ path: sourcePath, destination, newSize: THUMBNAILS_SIZE, keepOriginal: true }) const data = await readFile(destination) await remove(destination).catch(() => undefined) return { filename: thumbnailFilename, data, size: data.byteLength, mimeType: 'image/jpeg', etag: createHash('sha256').update(data).digest('hex') } } async function updateEventState (req: express.Request, res: express.Response) { const { state } = req.body const event = (res.locals as any).event const channel = (res.locals as any).channel const oldEventAuditView = new EventAuditView(event.toFormattedJSON()) // Check if user is channel owner or admin const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to manage this channel' }) } await sequelizeTypescript.transaction(async t => { await event.update({ state }, { transaction: t }) }) auditLogger.update(getAuditIdFromRes(res), new EventAuditView(event.toFormattedJSON()), oldEventAuditView) return res.sendStatus(HttpStatusCode.NO_CONTENT_204) } async function createEventRegistration (req: express.Request, res: express.Response) { const event = (res.locals as any).event const registration = await sequelizeTypescript.transaction(async t => { return EventRegistrationModel.create({ firstName: req.body.firstName, lastName: req.body.lastName, email: req.body.email, phone: req.body.phone, companyName: req.body.companyName, jobTitle: req.body.jobTitle, country: req.body.country, eventId: event.id }, { transaction: t }) }) return res.status(HttpStatusCode.CREATED_201).json(registration.toFormattedJSON()) } async function updateEventRegistration (req: express.Request, res: express.Response) { const event = (res.locals as any).event const registration = (res.locals as any).eventRegistration as EventRegistrationModel if (!canManageRegistration(res, event, registration)) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to update this registration' }) } await sequelizeTypescript.transaction(async t => { await registration.update({ firstName: req.body.firstName ?? registration.firstName, lastName: req.body.lastName ?? registration.lastName, email: req.body.email ?? registration.email, phone: req.body.phone ?? registration.phone, companyName: req.body.companyName ?? registration.companyName, jobTitle: req.body.jobTitle ?? registration.jobTitle, country: req.body.country ?? registration.country }, { transaction: t }) }) return res.json(registration.toFormattedJSON()) } async function deleteEventRegistration (req: express.Request, res: express.Response) { const event = (res.locals as any).event const registration = (res.locals as any).eventRegistration as EventRegistrationModel if (!canManageRegistration(res, event, registration)) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User is not allowed to delete this registration' }) } await sequelizeTypescript.transaction(async t => { await registration.destroy({ transaction: t }) }) return res.sendStatus(HttpStatusCode.NO_CONTENT_204) } function isEventOwnerOrAdmin (res: express.Response, event: EventModel) { const user = res.locals.oauth.token.User const isEventOwner = event.Channel?.Account?.userId === user.id return isEventOwner || user.role === 0 } function canManageRegistration (res: express.Response, event: EventModel, registration: EventRegistrationModel) { return isEventOwnerOrAdmin(res, event) } export { eventsRouter }