import { FileStorage, UserExportState, type FileStorageType, type UserExport, type UserExportStateType } from '@peertube/peertube-models' import { logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' import { JWT_TOKEN_USER_EXPORT_FILE_LIFETIME, STATIC_DOWNLOAD_PATHS, USER_EXPORT_FILE_PREFIX, USER_EXPORT_STATES, WEBSERVER } from '@server/initializers/constants.js' import { removeUserExportObjectStorage } from '@server/lib/object-storage/user-export.js' import { getFSUserExportFilePath } from '@server/lib/paths.js' import { MUserAccountId, MUserExport } from '@server/types/models/index.js' import { remove } from 'fs-extra/esm' import jwt from 'jsonwebtoken' import { join } from 'path' import { FindOptions, Op } from 'sequelize' import { AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' import { doesExist } from '../shared/query.js' import { SequelizeModel } from '../shared/sequelize-type.js' import { getSort } from '../shared/sort.js' import { UserModel } from './user.js' @Table({ tableName: 'userExport', indexes: [ { fields: [ 'userId' ] }, { fields: [ 'filename' ], unique: true } ] }) export class UserExportModel extends SequelizeModel { @CreatedAt createdAt: Date @UpdatedAt updatedAt: Date @AllowNull(true) @Column filename: string @AllowNull(false) @Column withVideoFiles: boolean @AllowNull(false) @Column state: UserExportStateType @AllowNull(true) @Column(DataType.TEXT) error: string @AllowNull(true) @Column(DataType.BIGINT) size: number @AllowNull(false) @Column storage: FileStorageType @AllowNull(true) @Column fileUrl: string @ForeignKey(() => UserModel) @Column userId: number @BelongsTo(() => UserModel, { foreignKey: { allowNull: false }, onDelete: 'CASCADE' }) User: Awaited @BeforeDestroy static removeFile (instance: UserExportModel) { logger.info('Removing user export file %s.', instance.filename) if (instance.storage === FileStorage.FILE_SYSTEM) { remove(getFSUserExportFilePath(instance)) .catch(err => logger.error('Cannot delete user export archive %s from filesystem.', instance.filename, { err })) } else { removeUserExportObjectStorage(instance) .catch(err => logger.error('Cannot delete user export archive %s from object storage.', instance.filename, { err })) } return undefined } static listByUser (user: MUserAccountId) { const query: FindOptions = { where: { userId: user.id } } return UserExportModel.findAll(query) } static listExpired (expirationTimeMS: number) { const query: FindOptions = { where: { createdAt: { [Op.lt]: new Date(new Date().getTime() + expirationTimeMS) } } } return UserExportModel.findAll(query) } static listForApi (options: { user: MUserAccountId start: number count: number }) { const { count, start, user } = options const query: FindOptions = { offset: start, limit: count, order: getSort('createdAt'), where: { userId: user.id } } return Promise.all([ UserExportModel.count(query), UserExportModel.findAll(query) ]).then(([ total, data ]) => ({ total, data })) } static load (id: number | string) { return UserExportModel.findByPk(id) } static loadByFilename (filename: string) { return UserExportModel.findOne({ where: { filename } }) } // --------------------------------------------------------------------------- static async doesOwnedFileExist (filename: string, storage: FileStorageType) { const query = 'SELECT 1 FROM "userExport" ' + `WHERE "filename" = $filename AND "storage" = $storage LIMIT 1` return doesExist({ sequelize: this.sequelize, query, bind: { filename, storage } }) } // --------------------------------------------------------------------------- generateAndSetFilename () { if (!this.userId) throw new Error('Cannot generate filename without userId') if (!this.createdAt) throw new Error('Cannot generate filename without createdAt') this.filename = `${USER_EXPORT_FILE_PREFIX}${this.userId}-${this.createdAt.toISOString()}.zip` } canBeSafelyRemoved () { const supportedStates = new Set([ UserExportState.COMPLETED, UserExportState.ERRORED, UserExportState.PENDING ]) return supportedStates.has(this.state) } generateJWT () { return jwt.sign( { userExportId: this.id }, CONFIG.SECRETS.PEERTUBE, { expiresIn: JWT_TOKEN_USER_EXPORT_FILE_LIFETIME, audience: this.filename, issuer: WEBSERVER.URL } ) } isJWTValid (jwtToken: string) { try { const payload = jwt.verify(jwtToken, CONFIG.SECRETS.PEERTUBE, { audience: this.filename, issuer: WEBSERVER.URL }) if ((payload as any).userExportId !== this.id) return false return true } catch { return false } } getFileDownloadUrl () { if (this.state !== UserExportState.COMPLETED) return null return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT() } // --------------------------------------------------------------------------- toFormattedJSON (this: MUserExport): UserExport { return { id: this.id, state: { id: this.state, label: USER_EXPORT_STATES[this.state] }, size: this.size, privateDownloadUrl: this.getFileDownloadUrl(), createdAt: this.createdAt.toISOString(), expiresOn: new Date(this.createdAt.getTime() + CONFIG.EXPORT.USERS.EXPORT_EXPIRATION).toISOString() } } }