mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-08-07 07:14:49 +00:00
feat(media): permission checks for accessing media uploads
Previously, media uploads were associated with one note upload but served regardless of the requesting user's permission. As we have a fine-grade permission system in place, we can use this as well for enforcing permission checks on media uploads. In order to make an upload reusable across multiple notes, this adds the capability to link one media upload to multiple notes and unlink that accordingly again. Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
@@ -3,17 +3,18 @@
|
|||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { MediaUploadSchema } from '@hedgedoc/commons';
|
import { MediaUploadSchema, PermissionLevel } from '@hedgedoc/commons';
|
||||||
import { PermissionLevel } from '@hedgedoc/commons';
|
import { FieldNameMediaUpload } from '@hedgedoc/database';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import { PermissionError } from '../../../errors/errors';
|
|||||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||||
import { MediaService } from '../../../media/media.service';
|
import { MediaService } from '../../../media/media.service';
|
||||||
import { MulterFile } from '../../../media/multer-file.interface';
|
import { MulterFile } from '../../../media/multer-file.interface';
|
||||||
|
import { NoteService } from '../../../notes/note.service';
|
||||||
import { PermissionService } from '../../../permissions/permission.service';
|
import { PermissionService } from '../../../permissions/permission.service';
|
||||||
import { PermissionsGuard } from '../../../permissions/permissions.guard';
|
import { PermissionsGuard } from '../../../permissions/permissions.guard';
|
||||||
import { RequirePermission } from '../../../permissions/require-permission.decorator';
|
import { RequirePermission } from '../../../permissions/require-permission.decorator';
|
||||||
@@ -40,6 +42,7 @@ export class MediaController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly logger: ConsoleLoggerService,
|
private readonly logger: ConsoleLoggerService,
|
||||||
private mediaService: MediaService,
|
private mediaService: MediaService,
|
||||||
|
private noteService: NoteService,
|
||||||
private permissionsService: PermissionService,
|
private permissionsService: PermissionService,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(MediaController.name);
|
this.logger.setContext(MediaController.name);
|
||||||
@@ -80,7 +83,7 @@ export class MediaController {
|
|||||||
@FastifyFile('file') file: MulterFile | undefined,
|
@FastifyFile('file') file: MulterFile | undefined,
|
||||||
@RequestNoteId() noteId: number,
|
@RequestNoteId() noteId: number,
|
||||||
@RequestUserId() userId: number,
|
@RequestUserId() userId: number,
|
||||||
): Promise<string> {
|
): Promise<MediaUploadDto> {
|
||||||
if (file === undefined) {
|
if (file === undefined) {
|
||||||
throw new BadRequestException('Request does not contain a file');
|
throw new BadRequestException('Request does not contain a file');
|
||||||
}
|
}
|
||||||
@@ -88,13 +91,46 @@ export class MediaController {
|
|||||||
`Received filename '${file.originalname}' for note '${noteId}' from user '${userId}'`,
|
`Received filename '${file.originalname}' for note '${noteId}' from user '${userId}'`,
|
||||||
'uploadMedia',
|
'uploadMedia',
|
||||||
);
|
);
|
||||||
return await this.mediaService.saveFile(file.originalname, file.buffer, userId, noteId);
|
const uploadUuid = await this.mediaService.saveFile(
|
||||||
|
file.originalname,
|
||||||
|
file.buffer,
|
||||||
|
userId,
|
||||||
|
noteId,
|
||||||
|
);
|
||||||
|
return await this.mediaService.getMediaUploadDtoByUuid(uploadUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':uuid/notes/:noteAlias')
|
||||||
|
@OpenApi(204, 403, 404, 500)
|
||||||
|
async linkMediaToNote(
|
||||||
|
@RequestUserId() userId: number,
|
||||||
|
@Param('uuid') uuid: string,
|
||||||
|
@Param('noteAlias') noteAlias: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const mediaUpload = await this.mediaService.findUploadByUuid(uuid);
|
||||||
|
const noteIdNumber = await this.noteService.getNoteIdByAlias(noteAlias);
|
||||||
|
if (mediaUpload[FieldNameMediaUpload.userId] !== userId) {
|
||||||
|
throw new PermissionError('Only the uploader may link this media upload to a note');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(await this.permissionsService.determinePermission(userId, noteIdNumber)) <
|
||||||
|
PermissionLevel.WRITE
|
||||||
|
) {
|
||||||
|
throw new PermissionError('You do not have permission to write to this note');
|
||||||
|
}
|
||||||
|
await this.mediaService.addNoteToMediaUpload(uuid, noteIdNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uuid')
|
@Get(':uuid')
|
||||||
@OpenApi(200, 404, 500)
|
@OpenApi(200, 404, 500)
|
||||||
async getMedia(@Param('uuid') uuid: string): Promise<MediaUploadDto> {
|
async getMedia(
|
||||||
return (await this.mediaService.getMediaUploadDtosByUuids([uuid]))[0];
|
@RequestUserId() userId: number,
|
||||||
|
@Param('uuid') uuid: string,
|
||||||
|
): Promise<MediaUploadDto> {
|
||||||
|
if (!(await this.mediaService.canUserAccessUpload(userId, uuid))) {
|
||||||
|
throw new PermissionError('You do not have permission to access this media upload');
|
||||||
|
}
|
||||||
|
return await this.mediaService.getMediaUploadDtoByUuid(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':uuid')
|
@Delete(':uuid')
|
||||||
@@ -106,12 +142,10 @@ export class MediaController {
|
|||||||
);
|
);
|
||||||
if (!hasUserMediaDeletePermission) {
|
if (!hasUserMediaDeletePermission) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`${userId} tried to delete '${uuid}', but is not the owner of upload or connected note`,
|
`User ${userId} tried to delete '${uuid}', but is not the owner of upload or connected note`,
|
||||||
'deleteMedia',
|
'deleteMedia',
|
||||||
);
|
);
|
||||||
throw new PermissionError(
|
throw new PermissionError(`User does neither own the upload '${uuid}' nor a linked note`);
|
||||||
`'${userId}' does neither own the upload '${uuid}' nor the note associacted with this upload'`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.logger.debug(`Deleting '${uuid}' for user '${userId}'`, 'deleteMedia');
|
this.logger.debug(`Deleting '${uuid}' for user '${userId}'`, 'deleteMedia');
|
||||||
await this.mediaService.deleteFile(uuid);
|
await this.mediaService.deleteFile(uuid);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export class NotesController {
|
|||||||
if (!noteMediaDeletionDto.keepMedia) {
|
if (!noteMediaDeletionDto.keepMedia) {
|
||||||
await this.mediaService.deleteFile(mediaUpload);
|
await this.mediaService.deleteFile(mediaUpload);
|
||||||
} else {
|
} else {
|
||||||
await this.mediaService.removeNoteFromMediaUpload(mediaUpload);
|
await this.mediaService.removeNoteFromMediaUpload(mediaUpload, noteId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.noteService.deleteNote(noteId);
|
await this.noteService.deleteNote(noteId);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { MediaUploadSchema, PermissionLevel } from '@hedgedoc/commons';
|
import { MediaUploadSchema, PermissionLevel } from '@hedgedoc/commons';
|
||||||
import { FieldNameMediaUpload } from '@hedgedoc/database';
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -78,7 +77,7 @@ export class MediaController {
|
|||||||
@RequirePermission(PermissionLevel.WRITE)
|
@RequirePermission(PermissionLevel.WRITE)
|
||||||
async uploadMedia(
|
async uploadMedia(
|
||||||
@RequestUserId() userId: number,
|
@RequestUserId() userId: number,
|
||||||
@FastifyFile('file') file: MulterFile,
|
@FastifyFile('file') file: MulterFile | undefined,
|
||||||
@RequestNoteId() noteId: number,
|
@RequestNoteId() noteId: number,
|
||||||
): Promise<MediaUploadDto> {
|
): Promise<MediaUploadDto> {
|
||||||
if (file === undefined) {
|
if (file === undefined) {
|
||||||
@@ -94,31 +93,34 @@ export class MediaController {
|
|||||||
userId,
|
userId,
|
||||||
noteId,
|
noteId,
|
||||||
);
|
);
|
||||||
return (await this.mediaService.getMediaUploadDtosByUuids([uploadUuid]))[0];
|
return await this.mediaService.getMediaUploadDtoByUuid(uploadUuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uuid')
|
@Get(':uuid')
|
||||||
@OpenApi(200, 404, 500)
|
@OpenApi(200, 404, 500)
|
||||||
async getMedia(@Param('uuid') uuid: string): Promise<MediaUploadDto> {
|
async getMedia(
|
||||||
return (await this.mediaService.getMediaUploadDtosByUuids([uuid]))[0];
|
@RequestUserId() userId: number,
|
||||||
|
@Param('uuid') uuid: string,
|
||||||
|
): Promise<MediaUploadDto> {
|
||||||
|
if (!(await this.mediaService.canUserAccessUpload(userId, uuid))) {
|
||||||
|
throw new PermissionError('You do not have permission to access this media upload.');
|
||||||
|
}
|
||||||
|
return await this.mediaService.getMediaUploadDtoByUuid(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':uuid')
|
@Delete(':uuid')
|
||||||
@OpenApi(204, 404, 500)
|
@OpenApi(204, 404, 500)
|
||||||
async deleteMedia(@RequestUserId() userId: number, @Param('uuid') uuid: string): Promise<void> {
|
async deleteMedia(@RequestUserId() userId: number, @Param('uuid') uuid: string): Promise<void> {
|
||||||
const mediaUpload = await this.mediaService.findUploadByUuid(uuid);
|
|
||||||
if (await this.permissionsService.checkMediaDeletePermission(userId, uuid)) {
|
if (await this.permissionsService.checkMediaDeletePermission(userId, uuid)) {
|
||||||
this.logger.debug(`Deleting '${uuid}' for user '${userId}'`, 'deleteMedia');
|
this.logger.debug(`Deleting '${uuid}' for user '${userId}'`, 'deleteMedia');
|
||||||
await this.mediaService.deleteFile(uuid);
|
await this.mediaService.deleteFile(uuid);
|
||||||
} else {
|
return;
|
||||||
this.logger.warn(
|
|
||||||
`${userId} tried to delete '${uuid}', but is not the owner of upload or connected note`,
|
|
||||||
'deleteMedia',
|
|
||||||
);
|
|
||||||
const mediaUploadNote = mediaUpload[FieldNameMediaUpload.noteId];
|
|
||||||
throw new PermissionError(
|
|
||||||
`Neither file '${uuid}' nor note '${mediaUploadNote ?? 'unknown'}'is owned by '${userId}'`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`${userId} tried to delete '${uuid}', but is not the owner of upload or connected note`,
|
||||||
|
'deleteMedia',
|
||||||
|
);
|
||||||
|
throw new PermissionError(`Neither file '${uuid}' nor a linked note is owned by the user`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export class NotesController {
|
|||||||
if (!noteMediaDeletionDto.keepMedia) {
|
if (!noteMediaDeletionDto.keepMedia) {
|
||||||
await this.mediaService.deleteFile(mediaUpload);
|
await this.mediaService.deleteFile(mediaUpload);
|
||||||
} else {
|
} else {
|
||||||
await this.mediaService.removeNoteFromMediaUpload(mediaUpload);
|
await this.mediaService.removeNoteFromMediaUpload(mediaUpload, noteId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.debug(`Deleting note: ${noteId}`, 'deleteNote');
|
this.logger.debug(`Deleting note: ${noteId}`, 'deleteNote');
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const {
|
|||||||
TableGroupUser,
|
TableGroupUser,
|
||||||
TableIdentity,
|
TableIdentity,
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
|
TableMediaUploadNote,
|
||||||
TableNote,
|
TableNote,
|
||||||
TableNoteGroupPermission,
|
TableNoteGroupPermission,
|
||||||
TableNoteUserPermission,
|
TableNoteUserPermission,
|
||||||
@@ -41,6 +42,7 @@ const {
|
|||||||
TableUserPinnedNote,
|
TableUserPinnedNote,
|
||||||
TableVisitedNote,
|
TableVisitedNote,
|
||||||
FieldNameSession,
|
FieldNameSession,
|
||||||
|
FieldNameMediaUploadNote,
|
||||||
TableSession,
|
TableSession,
|
||||||
} = require('@hedgedoc/database');
|
} = require('@hedgedoc/database');
|
||||||
|
|
||||||
@@ -318,13 +320,6 @@ const up = async function (knex) {
|
|||||||
// Create media_upload table
|
// Create media_upload table
|
||||||
await knex.schema.createTable(TableMediaUpload, (table) => {
|
await knex.schema.createTable(TableMediaUpload, (table) => {
|
||||||
table.uuid(FieldNameMediaUpload.uuid).primary();
|
table.uuid(FieldNameMediaUpload.uuid).primary();
|
||||||
table
|
|
||||||
.integer(FieldNameMediaUpload.noteId)
|
|
||||||
.unsigned()
|
|
||||||
.nullable()
|
|
||||||
.references(FieldNameNote.id)
|
|
||||||
.inTable(TableNote)
|
|
||||||
.onDelete('SET NULL');
|
|
||||||
table
|
table
|
||||||
.integer(FieldNameMediaUpload.userId)
|
.integer(FieldNameMediaUpload.userId)
|
||||||
.unsigned()
|
.unsigned()
|
||||||
@@ -353,10 +348,32 @@ const up = async function (knex) {
|
|||||||
useTz: false,
|
useTz: false,
|
||||||
precision: 3,
|
precision: 3,
|
||||||
});
|
});
|
||||||
table.index([FieldNameMediaUpload.noteId], 'idx_media_upload_note_id');
|
|
||||||
table.index([FieldNameMediaUpload.userId], 'idx_media_upload_user_id');
|
table.index([FieldNameMediaUpload.userId], 'idx_media_upload_user_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create media_upload_note join table
|
||||||
|
await knex.schema.createTable(TableMediaUploadNote, (table) => {
|
||||||
|
table
|
||||||
|
.uuid(FieldNameMediaUploadNote.mediaUploadUuid)
|
||||||
|
.notNullable()
|
||||||
|
.references(FieldNameMediaUpload.uuid)
|
||||||
|
.inTable(TableMediaUpload)
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.integer(FieldNameMediaUploadNote.noteId)
|
||||||
|
.unsigned()
|
||||||
|
.notNullable()
|
||||||
|
.references(FieldNameNote.id)
|
||||||
|
.inTable(TableNote)
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.primary([FieldNameMediaUploadNote.mediaUploadUuid, FieldNameMediaUploadNote.noteId]);
|
||||||
|
table.index(
|
||||||
|
[FieldNameMediaUploadNote.mediaUploadUuid],
|
||||||
|
'idx_media_upload_note_media_upload_uuid',
|
||||||
|
);
|
||||||
|
table.index([FieldNameMediaUploadNote.noteId], 'idx_media_upload_note_note_id');
|
||||||
|
});
|
||||||
|
|
||||||
// Create user_pinned_note table
|
// Create user_pinned_note table
|
||||||
await knex.schema.createTable(TableUserPinnedNote, (table) => {
|
await knex.schema.createTable(TableUserPinnedNote, (table) => {
|
||||||
table
|
table
|
||||||
@@ -451,6 +468,7 @@ const down = async function (knex) {
|
|||||||
await knex.schema.dropTableIfExists(TableSession);
|
await knex.schema.dropTableIfExists(TableSession);
|
||||||
await knex.schema.dropTableIfExists(TableVisitedNote);
|
await knex.schema.dropTableIfExists(TableVisitedNote);
|
||||||
await knex.schema.dropTableIfExists(TableUserPinnedNote);
|
await knex.schema.dropTableIfExists(TableUserPinnedNote);
|
||||||
|
await knex.schema.dropTableIfExists(TableMediaUploadNote);
|
||||||
await knex.schema.dropTableIfExists(TableMediaUpload);
|
await knex.schema.dropTableIfExists(TableMediaUpload);
|
||||||
await knex.schema.dropTableIfExists(TableNoteGroupPermission);
|
await knex.schema.dropTableIfExists(TableNoteGroupPermission);
|
||||||
await knex.schema.dropTableIfExists(TableNoteUserPermission);
|
await knex.schema.dropTableIfExists(TableNoteUserPermission);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GroupUser,
|
GroupUser,
|
||||||
Identity,
|
Identity,
|
||||||
MediaUpload,
|
MediaUpload,
|
||||||
|
MediaUploadNote,
|
||||||
Note,
|
Note,
|
||||||
NoteGroupPermission,
|
NoteGroupPermission,
|
||||||
NoteUserPermission,
|
NoteUserPermission,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
TableGroupUser,
|
TableGroupUser,
|
||||||
TableIdentity,
|
TableIdentity,
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
|
TableMediaUploadNote,
|
||||||
TableNote,
|
TableNote,
|
||||||
TableNoteGroupPermission,
|
TableNoteGroupPermission,
|
||||||
TableNoteUserPermission,
|
TableNoteUserPermission,
|
||||||
@@ -62,6 +64,7 @@ declare module 'knex/types/tables.js' {
|
|||||||
MediaUpload,
|
MediaUpload,
|
||||||
TypeUpdateMediaUpload
|
TypeUpdateMediaUpload
|
||||||
>;
|
>;
|
||||||
|
[TableMediaUploadNote]: MediaUploadNote;
|
||||||
[TableNote]: KnexOriginal.CompositeTableType<Note, TypeInsertNote, TypeUpdateNote>;
|
[TableNote]: KnexOriginal.CompositeTableType<Note, TypeInsertNote, TypeUpdateNote>;
|
||||||
[TableNoteGroupPermission]: KnexOriginal.CompositeTableType<
|
[TableNoteGroupPermission]: KnexOriginal.CompositeTableType<
|
||||||
NoteGroupPermission,
|
NoteGroupPermission,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { FastifyReply } from 'fastify';
|
|||||||
import { OpenApi } from '../api/utils/decorators/openapi.decorator';
|
import { OpenApi } from '../api/utils/decorators/openapi.decorator';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
import { MediaService } from '../media/media.service';
|
import { MediaService } from '../media/media.service';
|
||||||
|
import { RequestUserId } from '../api/utils/decorators/request-user-id.decorator';
|
||||||
|
import { PermissionError } from '../errors/errors';
|
||||||
|
|
||||||
@OpenApi()
|
@OpenApi()
|
||||||
@ApiTags('media-redirect')
|
@ApiTags('media-redirect')
|
||||||
@@ -24,8 +26,15 @@ export class MediaRedirectController {
|
|||||||
|
|
||||||
@Get(':uuid')
|
@Get(':uuid')
|
||||||
@OpenApi(302, 404, 500)
|
@OpenApi(302, 404, 500)
|
||||||
async getMedia(@Param('uuid') uuid: string, @Res() response: FastifyReply): Promise<void> {
|
async getMedia(
|
||||||
|
@RequestUserId() userId: number,
|
||||||
|
@Param('uuid') uuid: string,
|
||||||
|
@Res() response: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await this.mediaService.canUserAccessUpload(userId, uuid))) {
|
||||||
|
throw new PermissionError('You do not have permission to access this media upload.');
|
||||||
|
}
|
||||||
const url = await this.mediaService.getFileUrl(uuid);
|
const url = await this.mediaService.getFileUrl(uuid);
|
||||||
response.redirect(url);
|
await response.redirect(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { ImgurBackend } from './backends/imgur-backend';
|
|||||||
import { S3Backend } from './backends/s3-backend';
|
import { S3Backend } from './backends/s3-backend';
|
||||||
import { WebdavBackend } from './backends/webdav-backend';
|
import { WebdavBackend } from './backends/webdav-backend';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
|
import { PermissionsModule } from '../permissions/permissions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [PermissionsModule],
|
||||||
providers: [
|
providers: [
|
||||||
MediaService,
|
MediaService,
|
||||||
FilesystemBackend,
|
FilesystemBackend,
|
||||||
|
|||||||
@@ -5,13 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterEach, jest } from '@jest/globals';
|
import { describe, it, expect, beforeAll, afterEach, jest } from '@jest/globals';
|
||||||
import {
|
import {
|
||||||
FieldNameAlias,
|
|
||||||
FieldNameMediaUpload,
|
FieldNameMediaUpload,
|
||||||
FieldNameUser,
|
FieldNameMediaUploadNote,
|
||||||
MediaBackendType,
|
MediaBackendType,
|
||||||
TableAlias,
|
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
TableUser,
|
TableMediaUploadNote,
|
||||||
} from '@hedgedoc/database';
|
} from '@hedgedoc/database';
|
||||||
import { Provider } from '@nestjs/common';
|
import { Provider } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
@@ -24,10 +22,11 @@ import appConfigMock from '../config/mock/app.config.mock';
|
|||||||
import databaseConfigMock from '../config/mock/database.config.mock';
|
import databaseConfigMock from '../config/mock/database.config.mock';
|
||||||
import mediaConfigMock from '../config/mock/media.config.mock';
|
import mediaConfigMock from '../config/mock/media.config.mock';
|
||||||
import { expectBindings } from '../database/mock/expect-bindings';
|
import { expectBindings } from '../database/mock/expect-bindings';
|
||||||
import { mockDelete, mockInsert, mockSelect, mockUpdate } from '../database/mock/mock-queries';
|
import { mockDelete, mockInsert, mockSelect } from '../database/mock/mock-queries';
|
||||||
import { mockKnexDb } from '../database/mock/provider';
|
import { mockKnexDb } from '../database/mock/provider';
|
||||||
import { ClientError, NotInDBError } from '../errors/errors';
|
import { ClientError, NotInDBError } from '../errors/errors';
|
||||||
import { LoggerModule } from '../logger/logger.module';
|
import { LoggerModule } from '../logger/logger.module';
|
||||||
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { dateTimeToDB, getCurrentDateTime } from '../utils/datetime';
|
import { dateTimeToDB, getCurrentDateTime } from '../utils/datetime';
|
||||||
import { FilesystemBackend } from './backends/filesystem-backend';
|
import { FilesystemBackend } from './backends/filesystem-backend';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
@@ -44,8 +43,6 @@ describe('MediaService', () => {
|
|||||||
const backendData = JSON.stringify({ ext: 'png' });
|
const backendData = JSON.stringify({ ext: 'png' });
|
||||||
const fileBuffer = Buffer.from('test');
|
const fileBuffer = Buffer.from('test');
|
||||||
const username = 'testuser';
|
const username = 'testuser';
|
||||||
const alias = 'note-alias';
|
|
||||||
const createdAt = '2025-11-05 20:39:25';
|
|
||||||
const createdAtIso = '2025-11-05T20:39:25.000Z';
|
const createdAtIso = '2025-11-05T20:39:25.000Z';
|
||||||
|
|
||||||
let service: MediaService;
|
let service: MediaService;
|
||||||
@@ -57,7 +54,12 @@ describe('MediaService', () => {
|
|||||||
[tracker, knexProvider] = mockKnexDb();
|
[tracker, knexProvider] = mockKnexDb();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [MediaService, knexProvider, FilesystemBackend],
|
providers: [
|
||||||
|
MediaService,
|
||||||
|
knexProvider,
|
||||||
|
FilesystemBackend,
|
||||||
|
{ provide: PermissionService, useValue: { canReadNote: jest.fn() } },
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
await ConfigModule.forRoot({
|
await ConfigModule.forRoot({
|
||||||
@@ -97,12 +99,17 @@ describe('MediaService', () => {
|
|||||||
FieldNameMediaUpload.backendType,
|
FieldNameMediaUpload.backendType,
|
||||||
FieldNameMediaUpload.createdAt,
|
FieldNameMediaUpload.createdAt,
|
||||||
FieldNameMediaUpload.fileName,
|
FieldNameMediaUpload.fileName,
|
||||||
FieldNameMediaUpload.noteId,
|
|
||||||
FieldNameMediaUpload.userId,
|
FieldNameMediaUpload.userId,
|
||||||
FieldNameMediaUpload.uuid,
|
FieldNameMediaUpload.uuid,
|
||||||
],
|
],
|
||||||
[{ [FieldNameMediaUpload.uuid]: uuid }],
|
[{ [FieldNameMediaUpload.uuid]: uuid }],
|
||||||
);
|
);
|
||||||
|
mockInsert(
|
||||||
|
tracker,
|
||||||
|
TableMediaUploadNote,
|
||||||
|
[FieldNameMediaUploadNote.mediaUploadUuid, FieldNameMediaUploadNote.noteId],
|
||||||
|
[{ [FieldNameMediaUploadNote.mediaUploadUuid]: uuid }],
|
||||||
|
);
|
||||||
jest
|
jest
|
||||||
.spyOn(service.mediaBackend, 'saveFile')
|
.spyOn(service.mediaBackend, 'saveFile')
|
||||||
.mockImplementationOnce(
|
.mockImplementationOnce(
|
||||||
@@ -121,7 +128,8 @@ describe('MediaService', () => {
|
|||||||
const result = await service.saveFile(fileName, fileBuffer, userId, noteId);
|
const result = await service.saveFile(fileName, fileBuffer, userId, noteId);
|
||||||
expect(result).toBe(uuid);
|
expect(result).toBe(uuid);
|
||||||
expectBindings(tracker, 'insert', [
|
expectBindings(tracker, 'insert', [
|
||||||
[backendData, backendType, dateTimeToDB(now), fileName, noteId, userId, uuid],
|
[backendData, backendType, dateTimeToDB(now), fileName, userId, uuid],
|
||||||
|
[uuid, noteId],
|
||||||
]);
|
]);
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -185,7 +193,9 @@ describe('MediaService', () => {
|
|||||||
it('returns file url if found', async () => {
|
it('returns file url if found', async () => {
|
||||||
mockSelect(
|
mockSelect(
|
||||||
tracker,
|
tracker,
|
||||||
[FieldNameMediaUpload.backendType, FieldNameMediaUpload.backendData],
|
[
|
||||||
|
FieldNameMediaUpload.backendType,
|
||||||
|
FieldNameMediaUpload.backendData],
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
FieldNameMediaUpload.uuid,
|
FieldNameMediaUpload.uuid,
|
||||||
{
|
{
|
||||||
@@ -206,19 +216,31 @@ describe('MediaService', () => {
|
|||||||
);
|
);
|
||||||
const result = await service.getFileUrl(uuid);
|
const result = await service.getFileUrl(uuid);
|
||||||
expect(result).toBe(`http://example.com/${fileName}`);
|
expect(result).toBe(`http://example.com/${fileName}`);
|
||||||
expectBindings(tracker, 'select', [[uuid]], true);
|
expect(tracker.history.select).toHaveLength(1);
|
||||||
|
expect(tracker.history.select[0].bindings).toEqual([uuid, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotInDBError if not found', async () => {
|
it('throws NotInDBError if not found', async () => {
|
||||||
mockSelect(
|
mockSelect(
|
||||||
tracker,
|
tracker,
|
||||||
[FieldNameMediaUpload.backendType, FieldNameMediaUpload.backendData],
|
[
|
||||||
|
FieldNameMediaUpload.backendType,
|
||||||
|
FieldNameMediaUpload.backendData,
|
||||||
|
FieldNameMediaUpload.fileName,
|
||||||
|
],
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
FieldNameMediaUpload.uuid,
|
FieldNameMediaUpload.uuid,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
await expect(service.getFileUrl(uuid)).rejects.toThrow(NotInDBError);
|
await expect(service.getFileResponse(uuid)).rejects.toThrow(NotInDBError);
|
||||||
expectBindings(tracker, 'select', [[uuid]], true);
|
});
|
||||||
|
|
||||||
|
it('throws NotInDBError when given an invalid uuid', async () => {
|
||||||
|
jest.spyOn(uuidModule, 'validate').mockReturnValueOnce(false);
|
||||||
|
await expect(service.getFileResponse(invalidUuid)).rejects.toThrow(
|
||||||
|
new NotInDBError('Invalid media upload id provided', 'MediaService', 'getFileResponse'),
|
||||||
|
);
|
||||||
|
expect(tracker.history.select).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,12 +278,12 @@ describe('MediaService', () => {
|
|||||||
|
|
||||||
describe('getMediaUploadUuidsByNoteId', () => {
|
describe('getMediaUploadUuidsByNoteId', () => {
|
||||||
it('returns uuids for note', async () => {
|
it('returns uuids for note', async () => {
|
||||||
const rows = [{ [FieldNameMediaUpload.uuid]: uuid }];
|
const rows = [{ [FieldNameMediaUploadNote.mediaUploadUuid]: uuid }];
|
||||||
mockSelect(
|
mockSelect(
|
||||||
tracker,
|
tracker,
|
||||||
[FieldNameMediaUpload.uuid],
|
[FieldNameMediaUploadNote.mediaUploadUuid],
|
||||||
TableMediaUpload,
|
TableMediaUploadNote,
|
||||||
FieldNameMediaUpload.noteId,
|
FieldNameMediaUploadNote.noteId,
|
||||||
rows,
|
rows,
|
||||||
);
|
);
|
||||||
const result = await service.getMediaUploadUuidsByNoteId(noteId);
|
const result = await service.getMediaUploadUuidsByNoteId(noteId);
|
||||||
@@ -270,15 +292,13 @@ describe('MediaService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('removeNoteFromMediaUpload', () => {
|
describe('removeNoteFromMediaUpload', () => {
|
||||||
it('updates noteId to null', async () => {
|
it('deletes note association', async () => {
|
||||||
mockUpdate(
|
mockDelete(tracker, TableMediaUploadNote, [
|
||||||
tracker,
|
FieldNameMediaUploadNote.mediaUploadUuid,
|
||||||
TableMediaUpload,
|
FieldNameMediaUploadNote.noteId,
|
||||||
[FieldNameMediaUpload.noteId],
|
]);
|
||||||
FieldNameMediaUpload.uuid,
|
await service.removeNoteFromMediaUpload(uuid, noteId);
|
||||||
);
|
expectBindings(tracker, 'delete', [[uuid, noteId]]);
|
||||||
await service.removeNoteFromMediaUpload(uuid);
|
|
||||||
expectBindings(tracker, 'update', [[null, uuid]]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,45 +314,20 @@ describe('MediaService', () => {
|
|||||||
|
|
||||||
describe('getMediaUploadDtosByUuids', () => {
|
describe('getMediaUploadDtosByUuids', () => {
|
||||||
it('returns media upload dtos', async () => {
|
it('returns media upload dtos', async () => {
|
||||||
const rows = [
|
const dtoSpy = jest.spyOn(service, 'getMediaUploadDtoByUuid').mockResolvedValueOnce({
|
||||||
{
|
uuid,
|
||||||
[FieldNameMediaUpload.uuid]: uuid,
|
fileName,
|
||||||
[FieldNameMediaUpload.fileName]: fileName,
|
linkedNoteCount: 1,
|
||||||
[FieldNameMediaUpload.createdAt]: createdAt,
|
createdAt: createdAtIso,
|
||||||
[FieldNameUser.username]: username,
|
username,
|
||||||
[FieldNameAlias.alias]: alias,
|
});
|
||||||
},
|
|
||||||
];
|
|
||||||
mockSelect(
|
|
||||||
tracker,
|
|
||||||
[
|
|
||||||
`${TableMediaUpload}"."${FieldNameMediaUpload.uuid}`,
|
|
||||||
`${TableMediaUpload}"."${FieldNameMediaUpload.fileName}`,
|
|
||||||
`${TableMediaUpload}"."${FieldNameMediaUpload.createdAt}`,
|
|
||||||
`${TableUser}"."${FieldNameUser.username}`,
|
|
||||||
`${TableAlias}"."${FieldNameAlias.alias}`,
|
|
||||||
],
|
|
||||||
TableMediaUpload,
|
|
||||||
FieldNameMediaUpload.uuid,
|
|
||||||
rows,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
joinTable: TableAlias,
|
|
||||||
keyLeft: FieldNameAlias.noteId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
joinTable: TableUser,
|
|
||||||
keyLeft: FieldNameUser.id,
|
|
||||||
keyRight: FieldNameMediaUpload.userId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const result = await service.getMediaUploadDtosByUuids([uuid]);
|
const result = await service.getMediaUploadDtosByUuids([uuid]);
|
||||||
|
expect(dtoSpy).toHaveBeenCalledWith(uuid);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
uuid,
|
uuid,
|
||||||
fileName,
|
fileName,
|
||||||
noteAlias: alias,
|
linkedNoteCount: 1,
|
||||||
createdAt: createdAtIso,
|
createdAt: createdAtIso,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { MediaBackendType } from '@hedgedoc/commons';
|
import { MediaBackendType, PermissionLevel } from '@hedgedoc/commons';
|
||||||
import {
|
import {
|
||||||
Alias,
|
|
||||||
FieldNameAlias,
|
|
||||||
FieldNameMediaUpload,
|
FieldNameMediaUpload,
|
||||||
|
FieldNameMediaUploadNote,
|
||||||
FieldNameNote,
|
FieldNameNote,
|
||||||
FieldNameUser,
|
FieldNameUser,
|
||||||
MediaUpload,
|
MediaUpload,
|
||||||
Note,
|
Note,
|
||||||
TableAlias,
|
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
|
TableMediaUploadNote,
|
||||||
TableUser,
|
TableUser,
|
||||||
User,
|
User,
|
||||||
} from '@hedgedoc/database';
|
} from '@hedgedoc/database';
|
||||||
@@ -28,6 +27,7 @@ import mediaConfiguration, { MediaConfig } from '../config/media.config';
|
|||||||
import { MediaUploadDto } from '../dtos/media-upload.dto';
|
import { MediaUploadDto } from '../dtos/media-upload.dto';
|
||||||
import { ClientError, NotInDBError } from '../errors/errors';
|
import { ClientError, NotInDBError } from '../errors/errors';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import {
|
import {
|
||||||
dateTimeToDB,
|
dateTimeToDB,
|
||||||
dateTimeToISOString,
|
dateTimeToISOString,
|
||||||
@@ -56,6 +56,8 @@ export class MediaService {
|
|||||||
|
|
||||||
@Inject(mediaConfiguration.KEY)
|
@Inject(mediaConfiguration.KEY)
|
||||||
private mediaConfig: MediaConfig,
|
private mediaConfig: MediaConfig,
|
||||||
|
|
||||||
|
private readonly permissionService: PermissionService,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(MediaService.name);
|
this.logger.setContext(MediaService.name);
|
||||||
this.mediaBackendType = this.chooseBackendType();
|
this.mediaBackendType = this.chooseBackendType();
|
||||||
@@ -105,7 +107,7 @@ export class MediaService {
|
|||||||
userId: User[FieldNameUser.id],
|
userId: User[FieldNameUser.id],
|
||||||
noteId: Note[FieldNameNote.id],
|
noteId: Note[FieldNameNote.id],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.logger.debug(`Saving file for note '${noteId}' and user '${userId}'`, 'saveFile');
|
this.logger.debug(`Saving file for user '${userId}'`, 'saveFile');
|
||||||
const fileTypeResult = await FileType.fromBuffer(fileBuffer);
|
const fileTypeResult = await FileType.fromBuffer(fileBuffer);
|
||||||
if (!fileTypeResult) {
|
if (!fileTypeResult) {
|
||||||
throw new ClientError('Could not detect file type.');
|
throw new ClientError('Could not detect file type.');
|
||||||
@@ -115,14 +117,19 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
const uuid = uuidV7();
|
const uuid = uuidV7();
|
||||||
const backendData = await this.mediaBackend.saveFile(uuid, fileBuffer, fileTypeResult);
|
const backendData = await this.mediaBackend.saveFile(uuid, fileBuffer, fileTypeResult);
|
||||||
await this.knex(TableMediaUpload).insert({
|
await this.knex.transaction(async (transaction) => {
|
||||||
[FieldNameMediaUpload.uuid]: uuid,
|
await transaction(TableMediaUpload).insert({
|
||||||
[FieldNameMediaUpload.fileName]: fileName,
|
[FieldNameMediaUpload.uuid]: uuid,
|
||||||
[FieldNameMediaUpload.userId]: userId,
|
[FieldNameMediaUpload.fileName]: fileName,
|
||||||
[FieldNameMediaUpload.noteId]: noteId,
|
[FieldNameMediaUpload.userId]: userId,
|
||||||
[FieldNameMediaUpload.backendType]: this.mediaBackendType,
|
[FieldNameMediaUpload.backendType]: this.mediaBackendType,
|
||||||
[FieldNameMediaUpload.backendData]: backendData,
|
[FieldNameMediaUpload.backendData]: backendData,
|
||||||
[FieldNameMediaUpload.createdAt]: dateTimeToDB(getCurrentDateTime()),
|
[FieldNameMediaUpload.createdAt]: dateTimeToDB(getCurrentDateTime()),
|
||||||
|
});
|
||||||
|
await transaction(TableMediaUploadNote).insert({
|
||||||
|
[FieldNameMediaUploadNote.mediaUploadUuid]: uuid,
|
||||||
|
[FieldNameMediaUploadNote.noteId]: noteId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return uuid;
|
return uuid;
|
||||||
}
|
}
|
||||||
@@ -139,7 +146,7 @@ export class MediaService {
|
|||||||
.select(FieldNameMediaUpload.backendData)
|
.select(FieldNameMediaUpload.backendData)
|
||||||
.where(FieldNameMediaUpload.uuid, uuid)
|
.where(FieldNameMediaUpload.uuid, uuid)
|
||||||
.first();
|
.first();
|
||||||
if (backendData == undefined) {
|
if (backendData === undefined) {
|
||||||
throw new NotInDBError(
|
throw new NotInDBError(
|
||||||
`Can't find backend data for '${uuid}'`,
|
`Can't find backend data for '${uuid}'`,
|
||||||
this.logger.getContext(),
|
this.logger.getContext(),
|
||||||
@@ -151,28 +158,50 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the URL to a media upload file
|
* Resolves a media upload into either a redirect URL (for backends that
|
||||||
|
* expose a publicly accessible, time-limited URL like Azure, S3, imgur, or
|
||||||
|
* WebDAV) or the file content + content type (for the local filesystem
|
||||||
|
* backend so the controller can stream the bytes.
|
||||||
*
|
*
|
||||||
* @param uuid the uuid of the file to get the URL for
|
* @param uuid The UUID of the media upload
|
||||||
* @returns the URL of the file
|
* @returns A discriminated union describing how the controller should respond
|
||||||
* @throws MediaBackendError if there was an error retrieving the url
|
|
||||||
*/
|
*/
|
||||||
async getFileUrl(uuid: string): Promise<string> {
|
async getFileResponse(uuid: string): Promise<MediaResponse> {
|
||||||
|
if (!validateUuid(uuid)) {
|
||||||
|
throw new NotInDBError(
|
||||||
|
'Invalid media upload id provided',
|
||||||
|
this.logger.getContext(),
|
||||||
|
'getFileResponse',
|
||||||
|
);
|
||||||
|
}
|
||||||
const mediaUpload = await this.knex(TableMediaUpload)
|
const mediaUpload = await this.knex(TableMediaUpload)
|
||||||
.select(FieldNameMediaUpload.backendType, FieldNameMediaUpload.backendData)
|
.select(
|
||||||
|
FieldNameMediaUpload.backendType,
|
||||||
|
FieldNameMediaUpload.backendData,
|
||||||
|
FieldNameMediaUpload.fileName,
|
||||||
|
)
|
||||||
.where(FieldNameMediaUpload.uuid, uuid)
|
.where(FieldNameMediaUpload.uuid, uuid)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (mediaUpload === undefined) {
|
if (mediaUpload === undefined) {
|
||||||
throw new NotInDBError(
|
throw new NotInDBError(
|
||||||
`Can't find backend data for '${uuid}'`,
|
`Can't find backend data for '${uuid}'`,
|
||||||
this.logger.getContext(),
|
this.logger.getContext(),
|
||||||
'getFileUrl',
|
'getFileResponse',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendName = mediaUpload[FieldNameMediaUpload.backendType];
|
const backendName = mediaUpload[FieldNameMediaUpload.backendType];
|
||||||
const backend = this.getBackendFromType(backendName);
|
|
||||||
const backendData = mediaUpload[FieldNameMediaUpload.backendData];
|
const backendData = mediaUpload[FieldNameMediaUpload.backendData];
|
||||||
return await backend.getFileUrl(uuid, backendData);
|
|
||||||
|
const backend = this.getBackendFromType(backendName);
|
||||||
|
if (backendName === MediaBackendType.FILESYSTEM) {
|
||||||
|
const fileResponse = await (backend as FilesystemBackend).getFileResponse(uuid, backendData);
|
||||||
|
return { type: 'file', ...fileResponse };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await backend.getFileUrl(uuid, backendData);
|
||||||
|
return { type: 'redirect', url };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,7 +217,7 @@ export class MediaService {
|
|||||||
.where(FieldNameMediaUpload.uuid, uuid)
|
.where(FieldNameMediaUpload.uuid, uuid)
|
||||||
.first();
|
.first();
|
||||||
if (mediaUpload === undefined) {
|
if (mediaUpload === undefined) {
|
||||||
throw new NotInDBError(`MediaUpload with uuid '${uuid}' not found`);
|
throw new NotInDBError(`MediaUpload with given uuid was not found`);
|
||||||
}
|
}
|
||||||
return mediaUpload;
|
return mediaUpload;
|
||||||
}
|
}
|
||||||
@@ -209,7 +238,7 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all uploads to a specific note
|
* Lists all uploads linked to a specific note
|
||||||
*
|
*
|
||||||
* @param noteId the specific user
|
* @param noteId the specific user
|
||||||
* @returns An array of media uploads owned by the user
|
* @returns An array of media uploads owned by the user
|
||||||
@@ -217,30 +246,135 @@ export class MediaService {
|
|||||||
async getMediaUploadUuidsByNoteId(
|
async getMediaUploadUuidsByNoteId(
|
||||||
noteId: number,
|
noteId: number,
|
||||||
): Promise<MediaUpload[FieldNameMediaUpload.uuid][]> {
|
): Promise<MediaUpload[FieldNameMediaUpload.uuid][]> {
|
||||||
return await this.knex.transaction(async (transaction) => {
|
const results = await this.knex(TableMediaUploadNote)
|
||||||
const results = await transaction(TableMediaUpload)
|
.select(FieldNameMediaUploadNote.mediaUploadUuid)
|
||||||
.select(FieldNameMediaUpload.uuid)
|
.where(FieldNameMediaUploadNote.noteId, noteId);
|
||||||
.where(FieldNameMediaUpload.noteId, noteId);
|
return results.map((result) => result[FieldNameMediaUploadNote.mediaUploadUuid]);
|
||||||
return results.map((result) => result[FieldNameMediaUpload.uuid]);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a note association from a media upload.
|
||||||
|
*
|
||||||
|
* @param uuid The UUID of the media upload
|
||||||
|
* @param noteId The ID of the note to disassociate from the media upload
|
||||||
|
*/
|
||||||
|
async removeNoteFromMediaUpload(uuid: string, noteId: number): Promise<void> {
|
||||||
|
this.logger.debug(
|
||||||
|
`Removing note '${noteId}' from mediaUpload: ${uuid}`,
|
||||||
|
'removeNoteFromMediaUpload',
|
||||||
|
);
|
||||||
|
await this.knex(TableMediaUploadNote)
|
||||||
|
.where(FieldNameMediaUploadNote.mediaUploadUuid, uuid)
|
||||||
|
.andWhere(FieldNameMediaUploadNote.noteId, noteId)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a note association to a media upload.
|
||||||
|
*
|
||||||
|
* @param uuid The UUID of the media upload
|
||||||
|
* @param noteId The ID of the note to associate with the media upload
|
||||||
|
*/
|
||||||
|
async addNoteToMediaUpload(uuid: string, noteId: number): Promise<void> {
|
||||||
|
await this.knex(TableMediaUploadNote)
|
||||||
|
.insert({
|
||||||
|
[FieldNameMediaUploadNote.mediaUploadUuid]: uuid,
|
||||||
|
[FieldNameMediaUploadNote.noteId]: noteId,
|
||||||
|
})
|
||||||
|
.onConflict([FieldNameMediaUploadNote.mediaUploadUuid, FieldNameMediaUploadNote.noteId])
|
||||||
|
.ignore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a user may access a media upload.
|
||||||
|
*
|
||||||
|
* @param userId The id of the user to check
|
||||||
|
* @param uuid The UUID of the media upload to check against
|
||||||
|
* @returns true if the user has access, false otherwise
|
||||||
|
*/
|
||||||
|
async canUserAccessUpload(userId: number, uuid: string): Promise<boolean> {
|
||||||
|
const mediaUpload = await this.knex(TableMediaUpload)
|
||||||
|
.select(FieldNameMediaUpload.userId)
|
||||||
|
.where(FieldNameMediaUpload.uuid, uuid)
|
||||||
|
.first();
|
||||||
|
if (mediaUpload === undefined) {
|
||||||
|
throw new NotInDBError(`MediaUpload with given uuid was not found`);
|
||||||
|
}
|
||||||
|
const linkedNoteIds = await this.getLinkedNoteIds(uuid);
|
||||||
|
|
||||||
|
if (userId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedNoteIds.length === 0) {
|
||||||
|
return mediaUpload[FieldNameMediaUpload.userId] === userId;
|
||||||
|
}
|
||||||
|
for (const noteId of linkedNoteIds) {
|
||||||
|
const linkedNotePermission = await this.permissionService.determinePermission(userId, noteId);
|
||||||
|
if (linkedNotePermission >= PermissionLevel.READ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a media upload DTO by UUID.
|
||||||
|
*
|
||||||
|
* @param uuid The UUID to fetch the media upload DTO for
|
||||||
|
* @returns The {@link MediaUploadDto}
|
||||||
|
*/
|
||||||
|
async getMediaUploadDtoByUuid(uuid: string): Promise<MediaUploadDto> {
|
||||||
|
const mediaUpload = await this.knex(TableMediaUpload)
|
||||||
|
.join(
|
||||||
|
TableUser,
|
||||||
|
`${TableUser}.${FieldNameUser.id}`,
|
||||||
|
`${TableMediaUpload}.${FieldNameMediaUpload.userId}`,
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
`${TableMediaUpload}.${FieldNameMediaUpload.uuid}`,
|
||||||
|
`${TableMediaUpload}.${FieldNameMediaUpload.fileName}`,
|
||||||
|
`${TableMediaUpload}.${FieldNameMediaUpload.createdAt}`,
|
||||||
|
`${TableUser}.${FieldNameUser.username}`,
|
||||||
|
)
|
||||||
|
.where(`${TableMediaUpload}.${FieldNameMediaUpload.uuid}`, uuid)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (mediaUpload === undefined) {
|
||||||
|
throw new NotInDBError(`MediaUpload with given uuid was not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaUploadDto.create({
|
||||||
|
uuid: mediaUpload[FieldNameMediaUpload.uuid],
|
||||||
|
fileName: mediaUpload[FieldNameMediaUpload.fileName],
|
||||||
|
linkedNoteCount: (await this.getLinkedNoteIds(uuid)).length,
|
||||||
|
createdAt: dateTimeToISOString(dbToDateTime(mediaUpload[FieldNameMediaUpload.createdAt])),
|
||||||
|
username: mediaUpload[FieldNameUser.username],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the note of a mediaUpload to null
|
* Retrieves media upload DTOs by a list of their UUIDs.
|
||||||
|
*
|
||||||
*
|
*
|
||||||
* @param uuid the media upload to be changed
|
|
||||||
*/
|
*/
|
||||||
async removeNoteFromMediaUpload(uuid: string): Promise<void> {
|
async getMediaUploadDtosByUuids(uuids: string[]): Promise<MediaUploadDto[]> {
|
||||||
this.logger.debug('Setting note to null for mediaUpload: ' + uuid, 'removeNoteFromMediaUpload');
|
return await Promise.all(uuids.map(async (uuid) => await this.getMediaUploadDtoByUuid(uuid)));
|
||||||
await this.knex(TableMediaUpload)
|
|
||||||
.update({
|
|
||||||
[FieldNameMediaUpload.noteId]: null,
|
|
||||||
})
|
|
||||||
.where(FieldNameMediaUpload.uuid, uuid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the backend type that is configured in the media configuration
|
* Retrieves an array of note IDs linked to the specified media upload UUID.
|
||||||
|
*/
|
||||||
|
private async getLinkedNoteIds(uuid: string): Promise<number[]> {
|
||||||
|
const results = await this.knex(TableMediaUploadNote)
|
||||||
|
.select(FieldNameMediaUploadNote.noteId)
|
||||||
|
.where(FieldNameMediaUploadNote.mediaUploadUuid, uuid);
|
||||||
|
return results.map((result) => result[FieldNameMediaUploadNote.noteId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the backend type that is configured in the media configuration.
|
||||||
*/
|
*/
|
||||||
private chooseBackendType(): MediaBackendType {
|
private chooseBackendType(): MediaBackendType {
|
||||||
switch (this.mediaConfig.backend.type as string) {
|
switch (this.mediaConfig.backend.type as string) {
|
||||||
@@ -279,50 +413,4 @@ export class MediaService {
|
|||||||
return this.moduleRef.get(WebdavBackend);
|
return this.moduleRef.get(WebdavBackend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves media upload DTOs by a list of their UUIDs
|
|
||||||
*
|
|
||||||
* @param uuids The UUIDs of the media uploads to retrieve
|
|
||||||
* @returns An array of MediaUploadDto objects containing the details of the media uploads
|
|
||||||
*/
|
|
||||||
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) =>
|
|
||||||
MediaUploadDto.create({
|
|
||||||
uuid: mediaUpload[FieldNameMediaUpload.uuid],
|
|
||||||
fileName: mediaUpload[FieldNameMediaUpload.fileName],
|
|
||||||
noteAlias: mediaUpload[FieldNameAlias.alias],
|
|
||||||
createdAt: dateTimeToISOString(dbToDateTime(mediaUpload[FieldNameMediaUpload.createdAt])),
|
|
||||||
username: mediaUpload[FieldNameUser.username],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PermissionLevel } from '@hedgedoc/commons';
|
|||||||
import {
|
import {
|
||||||
FieldNameGroup,
|
FieldNameGroup,
|
||||||
FieldNameMediaUpload,
|
FieldNameMediaUpload,
|
||||||
|
FieldNameMediaUploadNote,
|
||||||
FieldNameNote,
|
FieldNameNote,
|
||||||
FieldNameNoteGroupPermission,
|
FieldNameNoteGroupPermission,
|
||||||
FieldNameNoteUserPermission,
|
FieldNameNoteUserPermission,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
Note,
|
Note,
|
||||||
TableGroup,
|
TableGroup,
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
|
TableMediaUploadNote,
|
||||||
TableNote,
|
TableNote,
|
||||||
TableNoteGroupPermission,
|
TableNoteGroupPermission,
|
||||||
TableNoteUserPermission,
|
TableNoteUserPermission,
|
||||||
@@ -68,24 +70,12 @@ export class PermissionService {
|
|||||||
userId: number,
|
userId: number,
|
||||||
mediaUploadUuid: string,
|
mediaUploadUuid: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const dbResult = await this.knex(TableMediaUpload)
|
const mediaUpload = await this.knex(TableMediaUpload)
|
||||||
.join(
|
.select(FieldNameMediaUpload.userId)
|
||||||
TableNote,
|
.where(FieldNameMediaUpload.uuid, mediaUploadUuid)
|
||||||
`${TableNote}.${FieldNameNote.id}`,
|
|
||||||
'=',
|
|
||||||
`${TableMediaUpload}.${FieldNameMediaUpload.noteId}`,
|
|
||||||
)
|
|
||||||
.select<{
|
|
||||||
[FieldNameMediaUpload.userId]: number;
|
|
||||||
[FieldNameNote.ownerId]: number;
|
|
||||||
}>(
|
|
||||||
`${TableMediaUpload}.${FieldNameMediaUpload.userId}`,
|
|
||||||
`${TableNote}.${FieldNameNote.ownerId}`,
|
|
||||||
)
|
|
||||||
.where(`${TableMediaUpload}.${FieldNameMediaUpload.uuid}`, mediaUploadUuid)
|
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (dbResult === undefined) {
|
if (mediaUpload === undefined) {
|
||||||
throw new NotInDBError(
|
throw new NotInDBError(
|
||||||
`There is no upload with the id ${mediaUploadUuid}`,
|
`There is no upload with the id ${mediaUploadUuid}`,
|
||||||
this.logger.getContext(),
|
this.logger.getContext(),
|
||||||
@@ -93,9 +83,34 @@ export class PermissionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (mediaUpload[FieldNameMediaUpload.userId] === userId) {
|
||||||
dbResult[FieldNameMediaUpload.userId] === userId || dbResult[FieldNameNote.ownerId] === userId
|
return true;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return await this.canDeleteViaAnyLinkedNote(userId, mediaUploadUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the user has permission to delete via any linked note
|
||||||
|
* associated with the given media upload UUID.
|
||||||
|
*
|
||||||
|
* @param userId The id of the user requesting deletion.
|
||||||
|
* @param mediaUploadUuid The UUID of the media upload.
|
||||||
|
* @returns A promise that resolves to `true` if the user has permission to delete through one of the linked notes, otherwise `false`.
|
||||||
|
*/
|
||||||
|
private async canDeleteViaAnyLinkedNote(
|
||||||
|
userId: number,
|
||||||
|
mediaUploadUuid: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const noteIds = await this.knex(TableMediaUploadNote)
|
||||||
|
.pluck(FieldNameMediaUploadNote.noteId)
|
||||||
|
.where(FieldNameMediaUploadNote.mediaUploadUuid, mediaUploadUuid);
|
||||||
|
for (const noteId of noteIds) {
|
||||||
|
if (await this.isOwner(userId, noteId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PermissionLevel } from '@hedgedoc/commons';
|
|||||||
import {
|
import {
|
||||||
FieldNameGroup,
|
FieldNameGroup,
|
||||||
FieldNameMediaUpload,
|
FieldNameMediaUpload,
|
||||||
|
FieldNameMediaUploadNote,
|
||||||
FieldNameNote,
|
FieldNameNote,
|
||||||
FieldNameNoteGroupPermission,
|
FieldNameNoteGroupPermission,
|
||||||
FieldNameNoteUserPermission,
|
FieldNameNoteUserPermission,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
TableGroup,
|
TableGroup,
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
|
TableMediaUploadNote,
|
||||||
TableNote,
|
TableNote,
|
||||||
TableNoteGroupPermission,
|
TableNoteGroupPermission,
|
||||||
TableNoteUserPermission,
|
TableNoteUserPermission,
|
||||||
@@ -109,70 +111,58 @@ describe('PermissionsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('checkMediaDeletePermission', () => {
|
describe('checkMediaDeletePermission', () => {
|
||||||
afterEach(() => {
|
const buildMockSelect = (uploadValues: unknown, linkedNoteValues?: unknown) => {
|
||||||
expectBindings(tracker, 'select', [[mockMediaUploadUuid]], true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildMockSelect = (returnValues: unknown) => {
|
|
||||||
mockSelect(
|
mockSelect(
|
||||||
tracker,
|
tracker,
|
||||||
[
|
[FieldNameMediaUpload.userId],
|
||||||
`${TableMediaUpload}"."${FieldNameMediaUpload.userId}`,
|
|
||||||
`${TableNote}"."${FieldNameNote.ownerId}`,
|
|
||||||
],
|
|
||||||
TableMediaUpload,
|
TableMediaUpload,
|
||||||
FieldNameMediaUpload.uuid,
|
FieldNameMediaUpload.uuid,
|
||||||
returnValues,
|
uploadValues,
|
||||||
[
|
|
||||||
{
|
|
||||||
joinTable: TableNote,
|
|
||||||
keyLeft: FieldNameNote.id,
|
|
||||||
keyRight: FieldNameMediaUpload.noteId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
if (linkedNoteValues !== undefined) {
|
||||||
|
mockSelect(
|
||||||
|
tracker,
|
||||||
|
[FieldNameMediaUploadNote.noteId],
|
||||||
|
TableMediaUploadNote,
|
||||||
|
FieldNameMediaUploadNote.mediaUploadUuid,
|
||||||
|
linkedNoteValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
it('throws NotInDBError if dbResult is undefined', async () => {
|
it('throws NotInDBError if dbResult is undefined', async () => {
|
||||||
buildMockSelect([]);
|
buildMockSelect(undefined);
|
||||||
await expect(
|
await expect(
|
||||||
service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
||||||
).rejects.toThrow(NotInDBError);
|
).rejects.toThrow(NotInDBError);
|
||||||
expectBindings(tracker, 'select', [[mockMediaUploadUuid]], true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('return true', () => {
|
describe('return true', () => {
|
||||||
it('for media owner', async () => {
|
it('for media owner', async () => {
|
||||||
buildMockSelect([
|
buildMockSelect([{ [FieldNameMediaUpload.userId]: mockUserId1 }]);
|
||||||
{
|
|
||||||
[FieldNameMediaUpload.userId]: mockUserId1,
|
|
||||||
[FieldNameNote.ownerId]: mockUserId2,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(
|
expect(
|
||||||
await service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
await service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
it('for note owner', async () => {
|
it('for note owner', async () => {
|
||||||
buildMockSelect([
|
buildMockSelect(
|
||||||
{
|
[{ [FieldNameMediaUpload.userId]: mockUserId2 }],
|
||||||
[FieldNameMediaUpload.userId]: mockUserId2,
|
[{ [FieldNameMediaUploadNote.noteId]: mockNoteId }],
|
||||||
[FieldNameNote.ownerId]: mockUserId1,
|
);
|
||||||
},
|
const spyOnIsOwner = jest.spyOn(service, 'isOwner').mockResolvedValue(true);
|
||||||
]);
|
|
||||||
expect(
|
expect(
|
||||||
await service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
await service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
expect(spyOnIsOwner).toHaveBeenCalledWith(mockUserId1, mockNoteId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for a non-owner', async () => {
|
it('returns false for a non-owner', async () => {
|
||||||
buildMockSelect([
|
buildMockSelect(
|
||||||
{
|
[{ [FieldNameMediaUpload.userId]: mockUserId2 }],
|
||||||
[FieldNameMediaUpload.userId]: mockUserId2,
|
[{ [FieldNameMediaUploadNote.noteId]: mockNoteId }],
|
||||||
[FieldNameNote.ownerId]: mockUserId2,
|
);
|
||||||
},
|
jest.spyOn(service, 'isOwner').mockResolvedValue(false);
|
||||||
]);
|
|
||||||
expect(
|
expect(
|
||||||
await service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
await service.checkMediaDeletePermission(mockUserId1, mockMediaUploadUuid),
|
||||||
).toBeFalsy();
|
).toBeFalsy();
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type { TestSetup } from '../test-setup';
|
|||||||
import { noteAlias1, TestSetupBuilder } from '../test-setup';
|
import { noteAlias1, TestSetupBuilder } from '../test-setup';
|
||||||
import { ensureDeleted } from '../utils';
|
import { ensureDeleted } from '../utils';
|
||||||
import { setupAgent } from './utils/setup-agent';
|
import { setupAgent } from './utils/setup-agent';
|
||||||
|
import { MediaUploadDto } from '../../src/dtos/media-upload.dto';
|
||||||
|
import { SpecialGroup } from '@hedgedoc/commons';
|
||||||
|
|
||||||
|
type PartialMediaUploadResponse = Pick<MediaUploadDto, 'fileName' | 'linkedNoteCount'>;
|
||||||
|
|
||||||
describe('Media', () => {
|
describe('Media', () => {
|
||||||
let testSetup: TestSetup;
|
let testSetup: TestSetup;
|
||||||
@@ -25,6 +29,7 @@ describe('Media', () => {
|
|||||||
|
|
||||||
let userId: number;
|
let userId: number;
|
||||||
let testImage: Buffer;
|
let testImage: Buffer;
|
||||||
|
const testFileName = 'test.png';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testSetup = await TestSetupBuilder.create().withUsers().withNotes().build();
|
testSetup = await TestSetupBuilder.create().withUsers().withNotes().build();
|
||||||
@@ -58,7 +63,7 @@ describe('Media', () => {
|
|||||||
.attach('file', 'test/private-api/fixtures/test.png')
|
.attach('file', 'test/private-api/fixtures/test.png')
|
||||||
.set('HedgeDoc-Note', noteAlias1)
|
.set('HedgeDoc-Note', noteAlias1)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
uuid = uploadResponse.text;
|
uuid = uploadResponse.body.uuid;
|
||||||
const apiResponse = await agentUser1.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
const apiResponse = await agentUser1.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
||||||
expect(apiResponse.statusCode).toEqual(200);
|
expect(apiResponse.statusCode).toEqual(200);
|
||||||
const downloadResponse = await agentUser1.get(`/uploads/${uuid}.png`);
|
const downloadResponse = await agentUser1.get(`/uploads/${uuid}.png`);
|
||||||
@@ -70,7 +75,7 @@ describe('Media', () => {
|
|||||||
.attach('file', 'test/private-api/fixtures/test.png')
|
.attach('file', 'test/private-api/fixtures/test.png')
|
||||||
.set('HedgeDoc-Note', noteAlias1.toUpperCase())
|
.set('HedgeDoc-Note', noteAlias1.toUpperCase())
|
||||||
.expect(201);
|
.expect(201);
|
||||||
uuid = uploadResponse.text;
|
uuid = uploadResponse.body.uuid;
|
||||||
const apiResponse = await agentUser1.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
const apiResponse = await agentUser1.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
||||||
expect(apiResponse.statusCode).toEqual(200);
|
expect(apiResponse.statusCode).toEqual(200);
|
||||||
const downloadResponse = await agentUser1.get(`/uploads/${uuid}.png`);
|
const downloadResponse = await agentUser1.get(`/uploads/${uuid}.png`);
|
||||||
@@ -88,7 +93,7 @@ describe('Media', () => {
|
|||||||
.attach('file', 'test/private-api/fixtures/test.png')
|
.attach('file', 'test/private-api/fixtures/test.png')
|
||||||
.set('HedgeDoc-Note', noteDtoResponse.body.metadata.primaryAlias)
|
.set('HedgeDoc-Note', noteDtoResponse.body.metadata.primaryAlias)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
uuid = uploadResponse.text;
|
uuid = uploadResponse.body.uuid;
|
||||||
const apiResponse = await agentGuestUser.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
const apiResponse = await agentGuestUser.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
||||||
expect(apiResponse.statusCode).toEqual(200);
|
expect(apiResponse.statusCode).toEqual(200);
|
||||||
const downloadResponse = await agentGuestUser.get(`/uploads/${uuid}.png`);
|
const downloadResponse = await agentGuestUser.get(`/uploads/${uuid}.png`);
|
||||||
@@ -106,7 +111,7 @@ describe('Media', () => {
|
|||||||
.attach('file', 'test/private-api/fixtures/test.png')
|
.attach('file', 'test/private-api/fixtures/test.png')
|
||||||
.set('HedgeDoc-Note', noteDtoResponse.body.metadata.primaryAlias.toUpperCase())
|
.set('HedgeDoc-Note', noteDtoResponse.body.metadata.primaryAlias.toUpperCase())
|
||||||
.expect(201);
|
.expect(201);
|
||||||
uuid = uploadResponse.text;
|
uuid = uploadResponse.body.uuid;
|
||||||
const apiResponse = await agentGuestUser.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
const apiResponse = await agentGuestUser.get(`${PRIVATE_API_PREFIX}/media/${uuid}`);
|
||||||
expect(apiResponse.statusCode).toEqual(200);
|
expect(apiResponse.statusCode).toEqual(200);
|
||||||
const downloadResponse = await agentGuestUser.get(`/uploads/${uuid}.png`);
|
const downloadResponse = await agentGuestUser.get(`/uploads/${uuid}.png`);
|
||||||
@@ -162,10 +167,53 @@ describe('Media', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(`GET ${PRIVATE_API_PREFIX}/media/:uuid`, () => {
|
||||||
|
let mediaUploadUuid: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
mediaUploadUuid = await testSetup.mediaService.saveFile(
|
||||||
|
testFileName,
|
||||||
|
testImage,
|
||||||
|
userId,
|
||||||
|
testSetup.ownedNoteIds[0],
|
||||||
|
);
|
||||||
|
// Make note non-readable to guests to test access forbidden case
|
||||||
|
const specialGroupEveryoneId = await testSetup.groupService.getGroupIdByName(
|
||||||
|
SpecialGroup.EVERYONE,
|
||||||
|
);
|
||||||
|
await testSetup.permissionsService.removeGroupPermission(
|
||||||
|
testSetup.ownedNoteIds[0],
|
||||||
|
specialGroupEveryoneId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('returns correct data for owner', async () => {
|
||||||
|
// agentUser1 is the uploader and therefore owner
|
||||||
|
const response = await agentUser1
|
||||||
|
.get(`${PRIVATE_API_PREFIX}/media/${mediaUploadUuid}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.headers['content-type']).toContain('application/json');
|
||||||
|
const jsonResponse = response.body as PartialMediaUploadResponse;
|
||||||
|
expect(jsonResponse.fileName).toEqual(testFileName);
|
||||||
|
expect(jsonResponse.linkedNoteCount).toEqual(1);
|
||||||
|
});
|
||||||
|
it('returns correct data for user with read-access to a linked note', async () => {
|
||||||
|
// agentUser2 has read-permission to the note via the logged-in special group
|
||||||
|
const response = await agentUser2
|
||||||
|
.get(`${PRIVATE_API_PREFIX}/media/${mediaUploadUuid}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.headers['content-type']).toContain('application/json');
|
||||||
|
const jsonResponse = response.body as PartialMediaUploadResponse;
|
||||||
|
expect(jsonResponse.fileName).toEqual(testFileName);
|
||||||
|
expect(jsonResponse.linkedNoteCount).toEqual(1);
|
||||||
|
});
|
||||||
|
it('rejects with PermissionError for user with no read-access to a linked note', async () => {
|
||||||
|
await agentGuestUser.get(`${PRIVATE_API_PREFIX}/media/${mediaUploadUuid}`).expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe(`DELETE ${PRIVATE_API_PREFIX}/media/:filename`, () => {
|
describe(`DELETE ${PRIVATE_API_PREFIX}/media/:filename`, () => {
|
||||||
it('allowed if user is owner of file', async () => {
|
it('allowed if user is owner of file', async () => {
|
||||||
const uuid = await testSetup.mediaService.saveFile(
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
'test.png',
|
testFileName,
|
||||||
testImage,
|
testImage,
|
||||||
userId,
|
userId,
|
||||||
testSetup.ownedNoteIds[0],
|
testSetup.ownedNoteIds[0],
|
||||||
@@ -179,7 +227,7 @@ describe('Media', () => {
|
|||||||
});
|
});
|
||||||
it('allowed if user is owner of note', async () => {
|
it('allowed if user is owner of note', async () => {
|
||||||
const uuid = await testSetup.mediaService.saveFile(
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
'test.png',
|
testFileName,
|
||||||
testImage,
|
testImage,
|
||||||
testSetup.userIds[1],
|
testSetup.userIds[1],
|
||||||
testSetup.ownedNoteIds[0],
|
testSetup.ownedNoteIds[0],
|
||||||
@@ -193,7 +241,7 @@ describe('Media', () => {
|
|||||||
});
|
});
|
||||||
it("other user can't delete", async () => {
|
it("other user can't delete", async () => {
|
||||||
const uuid = await testSetup.mediaService.saveFile(
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
'test.png',
|
testFileName,
|
||||||
testImage,
|
testImage,
|
||||||
testSetup.userIds[0],
|
testSetup.userIds[0],
|
||||||
testSetup.ownedNoteIds[0],
|
testSetup.ownedNoteIds[0],
|
||||||
@@ -203,7 +251,7 @@ describe('Media', () => {
|
|||||||
});
|
});
|
||||||
it("guest user can't delete", async () => {
|
it("guest user can't delete", async () => {
|
||||||
const uuid = await testSetup.mediaService.saveFile(
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
'test.png',
|
testFileName,
|
||||||
testImage,
|
testImage,
|
||||||
testSetup.userIds[0],
|
testSetup.userIds[0],
|
||||||
testSetup.ownedNoteIds[0],
|
testSetup.ownedNoteIds[0],
|
||||||
@@ -212,4 +260,55 @@ describe('Media', () => {
|
|||||||
await agentGuestUser.delete(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(403);
|
await agentGuestUser.delete(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(`PUT ${PRIVATE_API_PREFIX}/media/:uuid/notes/:noteAlias`, () => {
|
||||||
|
it('links media to a note by alias', async () => {
|
||||||
|
const targetAlias = 'media_link_target';
|
||||||
|
const targetNoteId = await testSetup.notesService.createNote(
|
||||||
|
'test content',
|
||||||
|
testSetup.userIds[0],
|
||||||
|
targetAlias,
|
||||||
|
);
|
||||||
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
|
testFileName,
|
||||||
|
testImage,
|
||||||
|
testSetup.userIds[0],
|
||||||
|
testSetup.ownedNoteIds[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await agentUser1
|
||||||
|
.put(`${PRIVATE_API_PREFIX}/media/${uuid}/notes/${targetAlias.toUpperCase()}`)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const mediaResponse = await agentUser1.get(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(200);
|
||||||
|
expect(mediaResponse.body.linkedNoteCount).toEqual(2);
|
||||||
|
|
||||||
|
const linkedUploads = await testSetup.mediaService.getMediaUploadUuidsByNoteId(targetNoteId);
|
||||||
|
expect(linkedUploads).toContain(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects users who are not the uploader', async () => {
|
||||||
|
const targetAlias = 'media_link_foreign_uploader';
|
||||||
|
await testSetup.notesService.createNote('test content', testSetup.userIds[1], targetAlias);
|
||||||
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
|
testFileName,
|
||||||
|
testImage,
|
||||||
|
testSetup.userIds[0],
|
||||||
|
testSetup.ownedNoteIds[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await agentUser2.put(`${PRIVATE_API_PREFIX}/media/${uuid}/notes/${targetAlias}`).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown note aliases', async () => {
|
||||||
|
const uuid = await testSetup.mediaService.saveFile(
|
||||||
|
testFileName,
|
||||||
|
testImage,
|
||||||
|
testSetup.userIds[0],
|
||||||
|
testSetup.ownedNoteIds[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await agentUser1.put(`${PRIVATE_API_PREFIX}/media/${uuid}/notes/does_not_exist`).expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe('Media', () => {
|
|||||||
expect(
|
expect(
|
||||||
isoStringToDateTime(mediaDto.createdAt).toMillis() - hardCodedNow.toMillis(),
|
isoStringToDateTime(mediaDto.createdAt).toMillis() - hardCodedNow.toMillis(),
|
||||||
).toBeLessThan(100);
|
).toBeLessThan(100);
|
||||||
expect(mediaDto.noteAlias).toEqual(noteAlias1);
|
expect(mediaDto.linkedNoteCount).toEqual(1);
|
||||||
expect(mediaDto.fileName).toEqual(fileName);
|
expect(mediaDto.fileName).toEqual(fileName);
|
||||||
expect(mediaDto.username).toEqual(username1);
|
expect(mediaDto.username).toEqual(username1);
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
@@ -164,7 +164,7 @@ describe('Media', () => {
|
|||||||
// upload a file with the default test user
|
// upload a file with the default test user
|
||||||
const testNote = await testSetup.notesService.createNote(
|
const testNote = await testSetup.notesService.createNote(
|
||||||
'test content',
|
'test content',
|
||||||
testSetup.userIds[2],
|
testSetup.userIds[0],
|
||||||
'test_delete_media_file',
|
'test_delete_media_file',
|
||||||
);
|
);
|
||||||
const upload = await testSetup.mediaService.saveFile(
|
const upload = await testSetup.mediaService.saveFile(
|
||||||
@@ -194,7 +194,7 @@ describe('Media', () => {
|
|||||||
// Test if file is really deleted
|
// Test if file is really deleted
|
||||||
await agent
|
await agent
|
||||||
.get(`/uploads/${upload}.png`)
|
.get(`/uploads/${upload}.png`)
|
||||||
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
|
.set('Authorization', `Bearer ${testSetup.authTokens[0].secret}`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
it('deleting user is owner of note', async () => {
|
it('deleting user is owner of note', async () => {
|
||||||
@@ -231,7 +231,7 @@ describe('Media', () => {
|
|||||||
// Test if file is really deleted
|
// Test if file is really deleted
|
||||||
await agent
|
await agent
|
||||||
.get(`/uploads/${upload}.png`)
|
.get(`/uploads/${upload}.png`)
|
||||||
.set('Authorization', `Bearer ${testSetup.authTokens[1].secret}`)
|
.set('Authorization', `Bearer ${testSetup.authTokens[2].secret}`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
it('errors if the user does not own the file', async () => {
|
it('errors if the user does not own the file', async () => {
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ export const MediaUploadSchema = z
|
|||||||
.object({
|
.object({
|
||||||
uuid: z.string().uuid().describe('The uuid of the media file'),
|
uuid: z.string().uuid().describe('The uuid of the media file'),
|
||||||
fileName: z.string().describe('The original filename of the media upload'),
|
fileName: z.string().describe('The original filename of the media upload'),
|
||||||
noteAlias: z
|
linkedNoteCount: z
|
||||||
.string()
|
.number()
|
||||||
.nullable()
|
.int()
|
||||||
.describe('The note alias to which the uploaded file is linked to'),
|
.nonnegative()
|
||||||
|
.describe('How many notes are linked to the upload'),
|
||||||
createdAt: z
|
createdAt: z
|
||||||
.string()
|
.string()
|
||||||
.datetime({ offset: false, local: false })
|
.datetime({ offset: false, local: false })
|
||||||
|
|||||||
@@ -14,16 +14,12 @@ export enum MediaBackendType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A media upload object represents an uploaded file. While the file itself is stored in the configured storage backend,
|
* A media upload object represents an uploaded file. While the file itself is stored in the configured storage backend,
|
||||||
* the metadata is stored in the database. Uploads are attached to the {@link Note} where they were uploaded, but can be
|
* the metadata is stored in the database. Uploads can be linked to zero or more notes.
|
||||||
* detached by deleting the note and setting the option to keep the media files.
|
|
||||||
*/
|
*/
|
||||||
export interface MediaUpload {
|
export interface MediaUpload {
|
||||||
/** UUID (v7) identifying the media upload. Is public and unique */
|
/** UUID (v7) identifying the media upload. Is public and unique */
|
||||||
[FieldNameMediaUpload.uuid]: string
|
[FieldNameMediaUpload.uuid]: string
|
||||||
|
|
||||||
/** The id of the attached {@link Note} or null if the media upload was detached from a note */
|
|
||||||
[FieldNameMediaUpload.noteId]: number | null
|
|
||||||
|
|
||||||
/** The id of the {@link User} who uploaded the media file */
|
/** The id of the {@link User} who uploaded the media file */
|
||||||
[FieldNameMediaUpload.userId]: number
|
[FieldNameMediaUpload.userId]: number
|
||||||
|
|
||||||
@@ -42,7 +38,6 @@ export interface MediaUpload {
|
|||||||
|
|
||||||
export enum FieldNameMediaUpload {
|
export enum FieldNameMediaUpload {
|
||||||
uuid = 'uuid',
|
uuid = 'uuid',
|
||||||
noteId = 'note_id',
|
|
||||||
userId = 'user_id',
|
userId = 'user_id',
|
||||||
fileName = 'file_name',
|
fileName = 'file_name',
|
||||||
backendType = 'backend_type',
|
backendType = 'backend_type',
|
||||||
@@ -52,4 +47,16 @@ export enum FieldNameMediaUpload {
|
|||||||
|
|
||||||
export const TableMediaUpload = 'media_upload'
|
export const TableMediaUpload = 'media_upload'
|
||||||
|
|
||||||
export type TypeUpdateMediaUpload = Pick<MediaUpload, FieldNameMediaUpload.noteId>
|
export const TableMediaUploadNote = 'media_upload_note'
|
||||||
|
|
||||||
|
export enum FieldNameMediaUploadNote {
|
||||||
|
mediaUploadUuid = 'media_upload_uuid',
|
||||||
|
noteId = 'note_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypeUpdateMediaUpload = never
|
||||||
|
|
||||||
|
export interface MediaUploadNote {
|
||||||
|
[FieldNameMediaUploadNote.mediaUploadUuid]: string
|
||||||
|
[FieldNameMediaUploadNote.noteId]: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
createdAt: '2022-03-20T20:36:32Z',
|
createdAt: '2022-03-20T20:36:32Z',
|
||||||
uuid: '5355ed83-7e12-4db0-95ed-837e124db08c',
|
uuid: '5355ed83-7e12-4db0-95ed-837e124db08c',
|
||||||
fileName: 'dummy.png',
|
fileName: 'dummy.png',
|
||||||
noteAlias: 'features'
|
linkedNoteCount: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tilman',
|
username: 'tilman',
|
||||||
createdAt: '2022-03-20T20:36:57+0000',
|
createdAt: '2022-03-20T20:36:57+0000',
|
||||||
uuid: '656745ab-fbf9-47f1-a745-abfbf9a7f10c',
|
uuid: '656745ab-fbf9-47f1-a745-abfbf9a7f10c',
|
||||||
fileName: 'dummy2.png',
|
fileName: 'dummy2.png',
|
||||||
noteAlias: null
|
linkedNoteCount: 1
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse): Promise<void>
|
|||||||
{
|
{
|
||||||
uuid: 'e81f57cd-5866-4253-9f57-cd5866a253ca',
|
uuid: 'e81f57cd-5866-4253-9f57-cd5866a253ca',
|
||||||
fileName: 'avatar.png',
|
fileName: 'avatar.png',
|
||||||
noteAlias: null,
|
linkedNoteCount: 1,
|
||||||
username: 'test',
|
username: 'test',
|
||||||
createdAt: '2022-02-27T21:54:23.856Z'
|
createdAt: '2022-02-27T21:54:23.856Z'
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user