diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4322e843df..0b832e3c08 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3465,6 +3465,266 @@ "x-immich-permission": "apiKey.rotate" } }, + "/asset-files": { + "get": { + "description": "Returns all matching asset files.", + "operationId": "searchAssetFiles", + "parameters": [ + { + "name": "assetId", + "required": true, + "in": "query", + "description": "Asset ID to filter files by", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "isEdited", + "required": false, + "in": "query", + "description": "The file was generated from an edit", + "schema": { + "type": "boolean" + } + }, + { + "name": "isProgressive", + "required": false, + "in": "query", + "description": "The file is a progressively encoded JPEG", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTransparent", + "required": false, + "in": "query", + "description": "The file is transparent", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Filter by type of file", + "schema": { + "$ref": "#/components/schemas/AssetFileType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFileResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Search asset files", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}": { + "delete": { + "description": "Delete a file and remove it from the database.", + "operationId": "deleteAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Returns metadata about a specific asset file.", + "operationId": "getAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFileResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}/download": { + "get": { + "description": "Serve the contents of a specific asset file.", + "operationId": "downloadAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.download", + "x-immich-state": "Alpha" + } + }, "/assets": { "delete": { "description": "Deletes multiple assets at the same time.", @@ -17105,6 +17365,10 @@ "name": "Assets", "description": "An asset is an image or video that has been uploaded to Immich." }, + { + "name": "Asset files", + "description": "An asset file is a file associated with an asset, including edited versions, thumbnails, etc." + }, { "name": "Authentication", "description": "Endpoints related to user authentication, including OAuth." @@ -19529,6 +19793,71 @@ ], "type": "object" }, + "AssetFileResponseDto": { + "properties": { + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Asset file ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "isEdited": { + "description": "The file was generated from an edit", + "type": "boolean" + }, + "isProgressive": { + "description": "The file is a progressively encoded JPEG", + "type": "boolean" + }, + "isTransparent": { + "description": "The file is transparent", + "type": "boolean" + }, + "path": { + "description": "File path", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetFileType" + }, + "updatedAt": { + "description": "Update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "createdAt", + "id", + "isEdited", + "isProgressive", + "isTransparent", + "path", + "type", + "updatedAt" + ], + "type": "object" + }, + "AssetFileType": { + "description": "Type of file", + "enum": [ + "fullsize", + "preview", + "thumbnail", + "sidecar", + "encoded_video" + ], + "type": "string" + }, "AssetIdErrorReason": { "description": "Error reason if failed", "enum": [ @@ -22921,6 +23250,9 @@ "asset.upload", "asset.copy", "asset.derive", + "assetFile.read", + "assetFile.delete", + "assetFile.download", "asset.edit.get", "asset.edit.create", "asset.edit.delete", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index d562f6877c..2834c63669 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1005,6 +1005,23 @@ export type ApiKeyUpdateDto = { /** List of permissions */ permissions?: Permission[]; }; +export type AssetFileResponseDto = { + /** Creation date */ + createdAt: string; + /** Asset file ID */ + id: string; + /** The file was generated from an edit */ + isEdited: boolean; + /** The file is a progressively encoded JPEG */ + isProgressive: boolean; + /** The file is transparent */ + isTransparent: boolean; + /** File path */ + path: string; + "type": AssetFileType; + /** Update date */ + updatedAt: string; +}; export type AssetBulkDeleteDto = { /** Force delete even if in use */ force?: boolean; @@ -4330,6 +4347,66 @@ export function rotateApiKey({ id }: { method: "POST" })); } +/** + * Search asset files + */ +export function searchAssetFiles({ assetId, isEdited, isProgressive, isTransparent, $type }: { + assetId: string; + isEdited?: boolean; + isProgressive?: boolean; + isTransparent?: boolean; + $type?: AssetFileType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto[]; + }>(`/asset-files${QS.query(QS.explode({ + assetId, + isEdited, + isProgressive, + isTransparent, + "type": $type + }))}`, { + ...opts + })); +} +/** + * Delete an asset file + */ +export function deleteAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/asset-files/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve an asset file + */ +export function getAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto; + }>(`/asset-files/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Download an asset file + */ +export function downloadAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/asset-files/${encodeURIComponent(id)}/download`, { + ...opts + })); +} /** * Delete assets */ @@ -7649,6 +7726,9 @@ export enum Permission { AssetUpload = "asset.upload", AssetCopy = "asset.copy", AssetDerive = "asset.derive", + AssetFileRead = "assetFile.read", + AssetFileDelete = "assetFile.delete", + AssetFileDownload = "assetFile.download", AssetEditGet = "asset.edit.get", AssetEditCreate = "asset.edit.create", AssetEditDelete = "asset.edit.delete", @@ -7794,6 +7874,13 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetFileType { + Fullsize = "fullsize", + Preview = "preview", + Thumbnail = "thumbnail", + Sidecar = "sidecar", + EncodedVideo = "encoded_video" +} export enum AssetMediaStatus { Created = "created", Duplicate = "duplicate" diff --git a/server/src/constants.ts b/server/src/constants.ts index 0ab0838547..856a6ffc57 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -148,6 +148,7 @@ export const endpointTags: Record = { [ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.', [ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.', [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', + [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', [ApiTag.ClusterGroups]: diff --git a/server/src/controllers/asset-file.controller.ts b/server/src/controllers/asset-file.controller.ts new file mode 100644 index 0000000000..5d50f54eea --- /dev/null +++ b/server/src/controllers/asset-file.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AssetFileResponseDto, AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetFileService } from 'src/services/asset-file.service'; +import { sendFile } from 'src/utils/file'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.AssetFiles) +@Controller('asset-files') +export class AssetFilesController { + constructor( + private service: AssetFileService, + private logger: LoggingRepository, + ) {} + + @Get() + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Search asset files', + description: 'Returns all matching asset files.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + searchAssetFiles(@Auth() auth: AuthDto, @Query() dto: AssetFileSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Retrieve an asset file', + description: 'Returns metadata about a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Delete(':id') + @Authenticated({ permission: Permission.AssetFileDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an asset file', + description: 'Delete a file and remove it from the database.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + deleteAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } + + @Get(':id/download') + @FileResponse() + @Authenticated({ permission: Permission.AssetFileDownload }) + @Endpoint({ + summary: 'Download an asset file', + description: 'Serve the contents of a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + async downloadAssetFile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.download(auth, id), this.logger); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e840654152..a50ab050e5 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetFilesController } from 'src/controllers/asset-file.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; @@ -50,6 +51,7 @@ export const controllers = [ AlbumController, AppController, AssetController, + AssetFilesController, AssetMediaController, AuthController, AuthAdminController, diff --git a/server/src/dtos/asset-file.dto.ts b/server/src/dtos/asset-file.dto.ts new file mode 100644 index 0000000000..54a772cd88 --- /dev/null +++ b/server/src/dtos/asset-file.dto.ts @@ -0,0 +1,45 @@ +import { Selectable } from 'kysely'; +import { createZodDto } from 'nestjs-zod'; +import { AssetFileTypeSchema } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { isoDatetimeToDate, stringToBool } from 'src/validation'; +import z from 'zod'; + +const AssetFileSearchSchema = z + .object({ + assetId: z.uuidv4().describe('Asset ID to filter files by'), + type: AssetFileTypeSchema.optional().describe('Filter by type of file'), + isEdited: stringToBool.optional().describe('The file was generated from an edit'), + isProgressive: stringToBool.optional().describe('The file is a progressively encoded JPEG'), + isTransparent: stringToBool.optional().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileSearchDto' }); + +const AssetFileResponseSchema = z + .object({ + id: z.uuidv4().describe('Asset file ID'), + createdAt: isoDatetimeToDate.describe('Creation date'), + updatedAt: isoDatetimeToDate.describe('Update date'), + type: AssetFileTypeSchema.describe('Type of file'), + path: z.string().describe('File path'), + isEdited: z.boolean().describe('The file was generated from an edit'), + isProgressive: z.boolean().describe('The file is a progressively encoded JPEG'), + isTransparent: z.boolean().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileResponseDto' }); + +export class AssetFileSearchDto extends createZodDto(AssetFileSearchSchema) {} +export class AssetFileResponseDto extends createZodDto(AssetFileResponseSchema) {} + +export const mapAssetFile = (file: Selectable): AssetFileResponseDto => { + return { + id: file.id, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + type: file.type, + path: file.path, + isEdited: file.isEdited, + isProgressive: file.isProgressive, + isTransparent: file.isTransparent, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 2b61999a4f..4be515a9e1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -62,6 +62,8 @@ export enum AssetFileType { EncodedVideo = 'encoded_video', } +export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Type of file').meta({ id: 'AssetFileType' }); + export enum AlbumUserRole { Editor = 'editor', Owner = 'owner', @@ -131,6 +133,10 @@ export enum Permission { AssetCopy = 'asset.copy', AssetDerive = 'asset.derive', + AssetFileRead = 'assetFile.read', + AssetFileDelete = 'assetFile.delete', + AssetFileDownload = 'assetFile.download', + AssetEditGet = 'asset.edit.get', AssetEditCreate = 'asset.edit.create', AssetEditDelete = 'asset.edit.delete', @@ -1203,6 +1209,7 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + AssetFiles = 'Asset files', ConfigUser = 'Config (user)', ConfigAdmin = 'Config (admin)', ConfigPublic = 'Config (public)', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 270bc915e4..d21941f126 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -149,6 +149,17 @@ where "albumAssets"."livePhotoVideoId" ] && array[$2]::uuid[] +-- AccessRepository.assetFile.checkOwnerAccess +select + "asset_file"."id" +from + "asset_file" + inner join "asset" on "asset"."id" = "asset_file"."assetId" +where + "asset"."visibility" != $1 + and "asset"."ownerId" = $2 + and "asset_file"."id" in ($3) + -- AccessRepository.authDevice.checkOwnerAccess select "session"."id" diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 62f2fcf6e9..9acb8c3fcb 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -278,6 +278,28 @@ class AssetAccess { } } +class AssetFileAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, fileIds: Set, hasElevatedPermission: boolean | undefined) { + if (fileIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('asset_file') + .select('asset_file.id') + .innerJoin('asset', 'asset.id', 'asset_file.assetId') + .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.Locked)) + .where('asset.ownerId', '=', userId) + .where('asset_file.id', 'in', [...fileIds]) + .execute() + .then((files) => new Set(files.map(({ id }) => id))); + } +} + class AuthDeviceAccess { constructor(private db: Kysely) {} @@ -596,6 +618,7 @@ export class AccessRepository { activity: ActivityAccess; album: AlbumAccess; asset: AssetAccess; + assetFile: AssetFileAccess; authDevice: AuthDeviceAccess; duplicate: DuplicateAccess; memory: MemoryAccess; @@ -614,6 +637,7 @@ export class AccessRepository { this.activity = new ActivityAccess(db); this.album = new AlbumAccess(db); this.asset = new AssetAccess(db); + this.assetFile = new AssetFileAccess(db); this.authDevice = new AuthDeviceAccess(db); this.duplicate = new DuplicateAccess(db); this.memory = new MemoryAccess(db); diff --git a/server/src/repositories/asset-file.repository.ts b/server/src/repositories/asset-file.repository.ts new file mode 100644 index 0000000000..ac0f51e60a --- /dev/null +++ b/server/src/repositories/asset-file.repository.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetFileRepository { + constructor(@InjectKysely() private db: Kysely) {} + + get(id: string) { + return this.db.selectFrom('asset_file').where('id', '=', id).selectAll().executeTakeFirst(); + } + + search(dto: AssetFileSearchDto) { + return this.db + .selectFrom('asset_file') + .where('assetId', '=', dto.assetId) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.isEdited !== undefined, (qb) => qb.where('isEdited', '=', dto.isEdited!)) + .$if(dto.isProgressive !== undefined, (qb) => qb.where('isProgressive', '=', dto.isProgressive!)) + .$if(dto.isTransparent !== undefined, (qb) => qb.where('isTransparent', '=', dto.isTransparent!)) + .selectAll() + .execute(); + } + + async delete(id: string): Promise { + const { numDeletedRows } = await this.db.deleteFrom('asset_file').where('id', '=', id).executeTakeFirst(); + return Number(numDeletedRows) === 1; + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 15ec7c353a..606f3b8b57 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -62,6 +63,7 @@ export const repositories = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ConfigRepository, CronRepository, diff --git a/server/src/services/asset-file.service.ts b/server/src/services/asset-file.service.ts new file mode 100644 index 0000000000..155d2d407c --- /dev/null +++ b/server/src/services/asset-file.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetFileResponseDto, AssetFileSearchDto, mapAssetFile } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetFileType, CacheControl, JobName, Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; +import { mimeTypes } from 'src/utils/mime-types'; +import { findOrFail } from 'src/utils/misc'; + +@Injectable() +export class AssetFileService extends BaseService { + async search(auth: AuthDto, dto: AssetFileSearchDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }); + const files = await this.assetFileRepository.search(dto); + return files.map((file) => mapAssetFile(file)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetFileRead, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + return mapAssetFile(file); + } + + async download(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDownload, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + + return new ImmichFileResponse({ + path: file.path, + fileName: getFileNameWithoutExtension(file.path) + getFilenameExtension(file.path), + contentType: mimeTypes.lookup(file.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDelete, ids: [id] }); + + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + // TODO consider implications of allowing sidecar files to be deleted + if (file.type === AssetFileType.Sidecar) { + throw new BadRequestException('Sidecar files cannot be deleted'); + } + + await this.assetFileRepository.delete(id); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } }); + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6c3d5e3df3..3c5dab1ed2 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -12,6 +12,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -74,6 +75,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ClusterGroupRepository, ConfigRepository, @@ -135,6 +137,7 @@ export class BaseService { protected appRepository: AppRepository, protected assetRepository: AssetRepository, protected assetEditRepository: AssetEditRepository, + protected assetFileRepository: AssetFileRepository, protected assetJobRepository: AssetJobRepository, protected clusterGroupRepository: ClusterGroupRepository, protected configRepository: ConfigRepository, @@ -205,6 +208,7 @@ export class BaseService { ctx.appRepository, ctx.assetRepository, ctx.assetEditRepository, + ctx.assetFileRepository, ctx.assetJobRepository, ctx.clusterGroupRepository, ctx.configRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index d8bb618b01..2a45d0308b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { ApiKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetFileService } from 'src/services/asset-file.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuthAdminService } from 'src/services/auth-admin.service'; @@ -56,6 +57,7 @@ export const services = [ ActivityService, AlbumService, ApiService, + AssetFileService, AssetMediaService, AssetService, AuthService, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 97c1e8e0c5..dc3c635ba7 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -126,6 +126,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return setUnion(isOwner, isPartner); } + case Permission.AssetFileDownload: { + return access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AssetView: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); @@ -164,6 +168,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetFileRead: + case Permission.AssetFileDelete: { + return await access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index a754d7bc28..f6a2497e7e 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -24,6 +24,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -467,6 +468,7 @@ const newRealRepository = (key: T, db: Kysely case ApiKeyRepository: case AssetRepository: case AssetEditRepository: + case AssetFileRepository: case AssetJobRepository: case ClusterGroupRepository: case DuplicateRepository: diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a928b4796d..90b9f64e0b 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -33,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()), }, + assetFile: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + album: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkSharedAlbumAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/utils.ts b/server/test/utils.ts index e7392949ad..e273d72a36 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -24,6 +24,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -239,6 +240,7 @@ export type ServiceOverrides = { app: AppRepository; asset: AssetRepository; assetEdit: AssetEditRepository; + assetFile: AssetFileRepository; assetJob: AssetJobRepository; clusterGroup: ClusterGroupRepository; config: ConfigRepository; @@ -323,6 +325,7 @@ export const getMocks = () => { albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), + assetFile: automock(AssetFileRepository), assetJob: automock(AssetJobRepository), clusterGroup: automock(ClusterGroupRepository), app: automock(AppRepository, { strict: false }), @@ -397,6 +400,7 @@ export const newTestService = ( overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), + overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), overrides.clusterGroup || (mocks.clusterGroup as As), overrides.config || (mocks.config as As as ConfigRepository),