mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-01 07:38:33 -04:00

Co-authored-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
import { MediaBackendType, MediaUploadDto } from '@hedgedoc/commons';
|
|
import {
|
|
Alias,
|
|
FieldNameAlias,
|
|
FieldNameMediaUpload,
|
|
FieldNameNote,
|
|
FieldNameUser,
|
|
MediaUpload,
|
|
Note,
|
|
TableAlias,
|
|
TableMediaUpload,
|
|
TableUser,
|
|
User,
|
|
} from '@hedgedoc/database';
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
import { ModuleRef } from '@nestjs/core';
|
|
import * as FileType from 'file-type';
|
|
import { Knex } from 'knex';
|
|
import { InjectConnection } from 'nest-knexjs';
|
|
import { v7 as uuidV7 } from 'uuid';
|
|
|
|
import mediaConfiguration, { MediaConfig } from '../config/media.config';
|
|
import { ClientError, NotInDBError } from '../errors/errors';
|
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
|
import { AzureBackend } from './backends/azure-backend';
|
|
import { FilesystemBackend } from './backends/filesystem-backend';
|
|
import { ImgurBackend } from './backends/imgur-backend';
|
|
import { S3Backend } from './backends/s3-backend';
|
|
import { WebdavBackend } from './backends/webdav-backend';
|
|
import { MediaBackend } from './media-backend.interface';
|
|
|
|
@Injectable()
|
|
export class MediaService {
|
|
mediaBackend: MediaBackend;
|
|
mediaBackendType: MediaBackendType;
|
|
|
|
constructor(
|
|
private readonly logger: ConsoleLoggerService,
|
|
|
|
@InjectConnection()
|
|
private readonly knex: Knex,
|
|
|
|
private moduleRef: ModuleRef,
|
|
|
|
@Inject(mediaConfiguration.KEY)
|
|
private mediaConfig: MediaConfig,
|
|
) {
|
|
this.logger.setContext(MediaService.name);
|
|
this.mediaBackendType = this.chooseBackendType();
|
|
this.mediaBackend = this.getBackendFromType(this.mediaBackendType);
|
|
}
|
|
|
|
private static isAllowedMimeType(mimeType: string): boolean {
|
|
const allowedTypes = [
|
|
'image/apng',
|
|
'image/bmp',
|
|
'image/gif',
|
|
'image/heif',
|
|
'image/heic',
|
|
'image/heif-sequence',
|
|
'image/heic-sequence',
|
|
'image/jpeg',
|
|
'image/png',
|
|
'image/svg+xml',
|
|
'image/tiff',
|
|
'image/webp',
|
|
];
|
|
return allowedTypes.includes(mimeType);
|
|
}
|
|
|
|
/**
|
|
* Saves the given buffer to the configured MediaBackend and creates a MediaUploadEntity
|
|
* to track where the file is, who uploaded it and to which note
|
|
*
|
|
* @param fileName The original file name
|
|
* @param fileBuffer The buffer with the file contents to save
|
|
* @param userId Id of the user who uploaded this file
|
|
* @param noteId Id of the note which will be associated with the new file
|
|
* @return The created MediaUpload entity
|
|
* @throws {ClientError} if the MIME type of the file is not supported
|
|
* @throws {NotInDBError} if the note or user is not in the database
|
|
* @throws {MediaBackendError} if there was an error saving the file
|
|
*/
|
|
async saveFile(
|
|
fileName: string,
|
|
fileBuffer: Buffer,
|
|
userId: User[FieldNameUser.id],
|
|
noteId: Note[FieldNameNote.id],
|
|
): Promise<MediaUpload[FieldNameMediaUpload.uuid]> {
|
|
this.logger.debug(
|
|
`Saving file for note '${noteId}' and user '${userId}'`,
|
|
'saveFile',
|
|
);
|
|
const fileTypeResult = await FileType.fromBuffer(fileBuffer);
|
|
if (!fileTypeResult) {
|
|
throw new ClientError('Could not detect file type.');
|
|
}
|
|
if (!MediaService.isAllowedMimeType(fileTypeResult.mime)) {
|
|
throw new ClientError('MIME type not allowed.');
|
|
}
|
|
const uuid = uuidV7();
|
|
const backendData = await this.mediaBackend.saveFile(
|
|
uuid,
|
|
fileBuffer,
|
|
fileTypeResult,
|
|
);
|
|
const mediaUploads = await this.knex(TableMediaUpload).insert(
|
|
{
|
|
[FieldNameMediaUpload.fileName]: fileName,
|
|
[FieldNameMediaUpload.userId]: userId,
|
|
[FieldNameMediaUpload.noteId]: noteId,
|
|
[FieldNameMediaUpload.backendType]: this.mediaBackendType,
|
|
[FieldNameMediaUpload.backendData]: backendData,
|
|
},
|
|
[FieldNameMediaUpload.uuid],
|
|
);
|
|
return mediaUploads[0][FieldNameMediaUpload.uuid];
|
|
}
|
|
|
|
/**
|
|
* @async
|
|
* Try to delete the specified file.
|
|
* @param {uuid} uuid - the name of the file to delete.
|
|
* @throws {MediaBackendError} - there was an error deleting the file
|
|
*/
|
|
async deleteFile(uuid: string): Promise<void> {
|
|
const backendData = await this.knex(TableMediaUpload)
|
|
.select(FieldNameMediaUpload.backendData)
|
|
.where(FieldNameMediaUpload.uuid, uuid)
|
|
.first();
|
|
if (backendData == undefined) {
|
|
throw new NotInDBError(
|
|
`Can't find backend data for '${uuid}'`,
|
|
this.logger.getContext(),
|
|
'deleteFile',
|
|
);
|
|
}
|
|
await this.mediaBackend.deleteFile(
|
|
uuid,
|
|
backendData[FieldNameMediaUpload.backendData],
|
|
);
|
|
await this.knex(TableMediaUpload)
|
|
.where(FieldNameMediaUpload.uuid, uuid)
|
|
.delete();
|
|
}
|
|
|
|
/**
|
|
* @async
|
|
* Get the URL of the file.
|
|
* @param {string} uuid - the uuid of the file to get the URL for.
|
|
* @return {string} the URL of the file.
|
|
* @throws {MediaBackendError} - there was an error retrieving the url
|
|
*/
|
|
async getFileUrl(uuid: string): Promise<string> {
|
|
const mediaUpload = await this.knex(TableMediaUpload)
|
|
.select(
|
|
FieldNameMediaUpload.backendType,
|
|
FieldNameMediaUpload.backendData,
|
|
)
|
|
.where(FieldNameMediaUpload.uuid, uuid)
|
|
.first();
|
|
if (mediaUpload === undefined) {
|
|
throw new NotInDBError(
|
|
`Can't find backend data for '${uuid}'`,
|
|
this.logger.getContext(),
|
|
'getFileUrl',
|
|
);
|
|
}
|
|
const backendName = mediaUpload[FieldNameMediaUpload.backendType];
|
|
const backend = this.getBackendFromType(backendName);
|
|
const backendData = mediaUpload[FieldNameMediaUpload.backendData];
|
|
return await backend.getFileUrl(uuid, backendData);
|
|
}
|
|
|
|
/**
|
|
* @async
|
|
* Find a file entry by its UUID.
|
|
* @param {string} uuid - The UUID of the MediaUpload entity to find.
|
|
* @returns {MediaUpload} - the MediaUpload entity if found.
|
|
* @throws {NotInDBError} - the MediaUpload entity with the provided UUID is not found in the database.
|
|
*/
|
|
async findUploadByUuid(uuid: string): Promise<MediaUpload> {
|
|
const mediaUpload = await this.knex(TableMediaUpload)
|
|
.select()
|
|
.where(FieldNameMediaUpload.uuid, uuid)
|
|
.first();
|
|
if (mediaUpload === undefined) {
|
|
throw new NotInDBError(`MediaUpload with uuid '${uuid}' not found`);
|
|
}
|
|
return mediaUpload;
|
|
}
|
|
|
|
/**
|
|
* @async
|
|
* List all uploads by a specific user
|
|
* @param {number} userId - the specific user
|
|
* @return {MediaUpload[]} arary of media uploads owned by the user
|
|
*/
|
|
async getMediaUploadUuidsByUserId(
|
|
userId: number,
|
|
): Promise<MediaUpload[FieldNameMediaUpload.uuid][]> {
|
|
const results = await this.knex(TableMediaUpload)
|
|
.select(FieldNameMediaUpload.uuid)
|
|
.where(FieldNameMediaUpload.userId, userId);
|
|
return results.map((result) => result[FieldNameMediaUpload.uuid]);
|
|
}
|
|
|
|
/**
|
|
* @async
|
|
* List all uploads to a specific note
|
|
* @param {number} noteId - the specific user
|
|
* @return {MediaUpload[]} array of media uploads owned by the user
|
|
*/
|
|
async getMediaUploadUuidsByNoteId(
|
|
noteId: number,
|
|
): Promise<MediaUpload[FieldNameMediaUpload.uuid][]> {
|
|
return await this.knex.transaction(async (transaction) => {
|
|
const results = await transaction(TableMediaUpload)
|
|
.select(FieldNameMediaUpload.uuid)
|
|
.where(FieldNameMediaUpload.noteId, noteId);
|
|
return results.map((result) => result[FieldNameMediaUpload.uuid]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @async
|
|
* Set the note of a mediaUpload to null
|
|
* @param {string} uuid - the media upload to be changed
|
|
*/
|
|
async removeNoteFromMediaUpload(uuid: string): Promise<void> {
|
|
this.logger.debug(
|
|
'Setting note to null for mediaUpload: ' + uuid,
|
|
'removeNoteFromMediaUpload',
|
|
);
|
|
await this.knex(TableMediaUpload)
|
|
.update({
|
|
[FieldNameMediaUpload.noteId]: null,
|
|
})
|
|
.where(FieldNameMediaUpload.uuid, uuid);
|
|
}
|
|
|
|
private chooseBackendType(): MediaBackendType {
|
|
switch (this.mediaConfig.backend.use as string) {
|
|
case 'filesystem':
|
|
return MediaBackendType.FILESYSTEM;
|
|
case 'azure':
|
|
return MediaBackendType.AZURE;
|
|
case 'imgur':
|
|
return MediaBackendType.IMGUR;
|
|
case 's3':
|
|
return MediaBackendType.S3;
|
|
case 'webdav':
|
|
return MediaBackendType.WEBDAV;
|
|
default:
|
|
throw new Error(
|
|
`Unexpected media backend ${this.mediaConfig.backend.use}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private getBackendFromType(type: MediaBackendType): MediaBackend {
|
|
switch (type) {
|
|
case MediaBackendType.FILESYSTEM:
|
|
return this.moduleRef.get(FilesystemBackend);
|
|
case MediaBackendType.S3:
|
|
return this.moduleRef.get(S3Backend);
|
|
case MediaBackendType.AZURE:
|
|
return this.moduleRef.get(AzureBackend);
|
|
case MediaBackendType.IMGUR:
|
|
return this.moduleRef.get(ImgurBackend);
|
|
case MediaBackendType.WEBDAV:
|
|
return this.moduleRef.get(WebdavBackend);
|
|
}
|
|
}
|
|
|
|
async getMediaUploadDtosByUuids(uuids: string[]): Promise<MediaUploadDto[]> {
|
|
const mediaUploads = await this.knex(TableMediaUpload)
|
|
.select<
|
|
(Pick<
|
|
MediaUpload,
|
|
| FieldNameMediaUpload.uuid
|
|
| FieldNameMediaUpload.fileName
|
|
| FieldNameMediaUpload.createdAt
|
|
> &
|
|
Pick<User, FieldNameUser.username> &
|
|
Pick<Alias, FieldNameAlias.alias>)[]
|
|
>(`${TableMediaUpload}.${FieldNameMediaUpload.uuid}`, `${TableMediaUpload}.${FieldNameMediaUpload.fileName}`, `${TableMediaUpload}.${FieldNameMediaUpload.createdAt}`, `${TableUser}.${FieldNameUser.username}`, `${TableAlias}.${FieldNameAlias.alias}`)
|
|
.join(
|
|
TableAlias,
|
|
`${TableAlias}.${FieldNameAlias.noteId}`,
|
|
`${TableMediaUpload}.${FieldNameMediaUpload.noteId}`,
|
|
)
|
|
.join(
|
|
TableUser,
|
|
`${TableUser}.${FieldNameUser.id}`,
|
|
`${TableMediaUpload}.${FieldNameMediaUpload.userId}`,
|
|
)
|
|
.whereIn(FieldNameMediaUpload.uuid, uuids)
|
|
.andWhere(FieldNameAlias.isPrimary, true);
|
|
|
|
return mediaUploads.map((mediaUpload) => ({
|
|
uuid: mediaUpload[FieldNameMediaUpload.uuid],
|
|
fileName: mediaUpload[FieldNameMediaUpload.fileName],
|
|
noteId: mediaUpload[FieldNameAlias.alias],
|
|
createdAt: new Date(
|
|
mediaUpload[FieldNameMediaUpload.createdAt],
|
|
).toISOString(),
|
|
username: mediaUpload[FieldNameUser.username],
|
|
}));
|
|
}
|
|
}
|