2024-06-03 16:37:44 +02:00
|
|
|
import { FileStorage, UserExportState, type FileStorageType, type UserExport, type UserExportStateType } from '@peertube/peertube-models'
|
2024-02-12 10:47:52 +01:00
|
|
|
import { logger } from '@server/helpers/logger.js'
|
2024-06-03 16:37:44 +02:00
|
|
|
import { CONFIG } from '@server/initializers/config.js'
|
2024-02-12 10:47:52 +01:00
|
|
|
import {
|
|
|
|
JWT_TOKEN_USER_EXPORT_FILE_LIFETIME,
|
|
|
|
STATIC_DOWNLOAD_PATHS,
|
2024-06-03 16:37:44 +02:00
|
|
|
USER_EXPORT_FILE_PREFIX,
|
2024-02-12 10:47:52 +01:00
|
|
|
USER_EXPORT_STATES,
|
|
|
|
WEBSERVER
|
|
|
|
} from '@server/initializers/constants.js'
|
|
|
|
import { removeUserExportObjectStorage } from '@server/lib/object-storage/user-export.js'
|
2024-06-03 16:37:44 +02:00
|
|
|
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'
|
2024-02-22 10:12:04 +01:00
|
|
|
import { SequelizeModel } from '../shared/sequelize-type.js'
|
2024-06-03 16:37:44 +02:00
|
|
|
import { getSort } from '../shared/sort.js'
|
|
|
|
import { UserModel } from './user.js'
|
2024-02-12 10:47:52 +01:00
|
|
|
|
|
|
|
@Table({
|
|
|
|
tableName: 'userExport',
|
|
|
|
indexes: [
|
|
|
|
{
|
|
|
|
fields: [ 'userId' ]
|
|
|
|
},
|
|
|
|
{
|
|
|
|
fields: [ 'filename' ],
|
|
|
|
unique: true
|
|
|
|
}
|
|
|
|
]
|
|
|
|
})
|
2024-02-22 10:12:04 +01:00
|
|
|
export class UserExportModel extends SequelizeModel<UserExportModel> {
|
2024-02-12 10:47:52 +01:00
|
|
|
@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)
|
2024-05-16 11:15:57 +02:00
|
|
|
@Column(DataType.BIGINT)
|
2024-02-12 10:47:52 +01:00
|
|
|
size: number
|
|
|
|
|
|
|
|
@AllowNull(false)
|
|
|
|
@Column
|
|
|
|
storage: FileStorageType
|
|
|
|
|
2024-03-15 15:47:18 +01:00
|
|
|
@AllowNull(true)
|
|
|
|
@Column
|
|
|
|
fileUrl: string
|
|
|
|
|
2024-02-12 10:47:52 +01:00
|
|
|
@ForeignKey(() => UserModel)
|
|
|
|
@Column
|
|
|
|
userId: number
|
|
|
|
|
|
|
|
@BelongsTo(() => UserModel, {
|
|
|
|
foreignKey: {
|
|
|
|
allowNull: false
|
|
|
|
},
|
|
|
|
onDelete: 'CASCADE'
|
|
|
|
})
|
|
|
|
User: Awaited<UserModel>
|
|
|
|
|
|
|
|
@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<MUserExport>(query)
|
|
|
|
}
|
|
|
|
|
|
|
|
static listExpired (expirationTimeMS: number) {
|
|
|
|
const query: FindOptions = {
|
|
|
|
where: {
|
|
|
|
createdAt: {
|
|
|
|
[Op.lt]: new Date(new Date().getTime() + expirationTimeMS)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return UserExportModel.findAll<MUserExport>(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<MUserExport>(query)
|
|
|
|
]).then(([ total, data ]) => ({ total, data }))
|
|
|
|
}
|
|
|
|
|
|
|
|
static load (id: number | string) {
|
|
|
|
return UserExportModel.findByPk<MUserExport>(id)
|
|
|
|
}
|
|
|
|
|
|
|
|
static loadByFilename (filename: string) {
|
|
|
|
return UserExportModel.findOne<MUserExport>({ where: { filename } })
|
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2024-06-03 16:37:44 +02:00
|
|
|
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 } })
|
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2024-02-12 10:47:52 +01:00
|
|
|
generateAndSetFilename () {
|
|
|
|
if (!this.userId) throw new Error('Cannot generate filename without userId')
|
|
|
|
if (!this.createdAt) throw new Error('Cannot generate filename without createdAt')
|
|
|
|
|
2024-06-03 16:37:44 +02:00
|
|
|
this.filename = `${USER_EXPORT_FILE_PREFIX}${this.userId}-${this.createdAt.toISOString()}.zip`
|
2024-02-12 10:47:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
canBeSafelyRemoved () {
|
|
|
|
const supportedStates = new Set<UserExportStateType>([ 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
|
|
|
|
|
2024-03-15 15:47:18 +01:00
|
|
|
return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT()
|
2024-02-12 10:47:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|