1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server, web): create shared link (#2879)

* refactor: shared links

* chore: open api

* fix: tsc error
This commit is contained in:
Jason Rasmussen 2023-06-20 21:08:43 -04:00 committed by GitHub
parent 746ca5d5ed
commit 868f629f32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1360 additions and 1488 deletions

View File

@ -37,8 +37,6 @@ doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md
doc/CreateAlbumDto.md
doc/CreateAlbumShareLinkDto.md
doc/CreateAssetsShareLinkDto.md
doc/CreateProfileImageResponseDto.md
doc/CreateTagDto.md
doc/CreateUserDto.md
@ -48,7 +46,6 @@ doc/DeleteAssetDto.md
doc/DeleteAssetResponseDto.md
doc/DeleteAssetStatus.md
doc/DownloadFilesDto.md
doc/EditSharedLinkDto.md
doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md
@ -89,7 +86,9 @@ doc/ServerInfoResponseDto.md
doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md
doc/ShareApi.md
doc/SharedLinkApi.md
doc/SharedLinkCreateDto.md
doc/SharedLinkEditDto.md
doc/SharedLinkResponseDto.md
doc/SharedLinkType.md
doc/SignUpDto.md
@ -128,7 +127,7 @@ lib/api/partner_api.dart
lib/api/person_api.dart
lib/api/search_api.dart
lib/api/server_info_api.dart
lib/api/share_api.dart
lib/api/shared_link_api.dart
lib/api/system_config_api.dart
lib/api/tag_api.dart
lib/api/user_api.dart
@ -170,8 +169,6 @@ lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart
lib/model/create_album_dto.dart
lib/model/create_album_share_link_dto.dart
lib/model/create_assets_share_link_dto.dart
lib/model/create_profile_image_response_dto.dart
lib/model/create_tag_dto.dart
lib/model/create_user_dto.dart
@ -181,7 +178,6 @@ lib/model/delete_asset_dto.dart
lib/model/delete_asset_response_dto.dart
lib/model/delete_asset_status.dart
lib/model/download_files_dto.dart
lib/model/edit_shared_link_dto.dart
lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart
@ -216,6 +212,8 @@ lib/model/server_info_response_dto.dart
lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
lib/model/shared_link_create_dto.dart
lib/model/shared_link_edit_dto.dart
lib/model/shared_link_response_dto.dart
lib/model/shared_link_type.dart
lib/model/sign_up_dto.dart
@ -274,8 +272,6 @@ test/check_duplicate_asset_response_dto_test.dart
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart
test/create_album_dto_test.dart
test/create_album_share_link_dto_test.dart
test/create_assets_share_link_dto_test.dart
test/create_profile_image_response_dto_test.dart
test/create_tag_dto_test.dart
test/create_user_dto_test.dart
@ -285,7 +281,6 @@ test/delete_asset_dto_test.dart
test/delete_asset_response_dto_test.dart
test/delete_asset_status_test.dart
test/download_files_dto_test.dart
test/edit_shared_link_dto_test.dart
test/exif_response_dto_test.dart
test/get_asset_by_time_bucket_dto_test.dart
test/get_asset_count_by_time_bucket_dto_test.dart
@ -326,7 +321,9 @@ test/server_info_response_dto_test.dart
test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart
test/server_version_reponse_dto_test.dart
test/share_api_test.dart
test/shared_link_api_test.dart
test/shared_link_create_dto_test.dart
test/shared_link_edit_dto_test.dart
test/shared_link_response_dto_test.dart
test/shared_link_type_test.dart
test/sign_up_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,14 @@
import { AlbumResponseDto, AuthService, CreateAlbumDto, SharedLinkResponseDto, UserService } from '@app/domain';
import { CreateAlbumShareLinkDto } from '@app/immich/api-v1/album/dto/create-album-shared-link.dto';
import {
AlbumResponseDto,
AuthService,
CreateAlbumDto,
SharedLinkCreateDto,
SharedLinkResponseDto,
UserService,
} from '@app/domain';
import { AppModule } from '@app/immich/app.module';
import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator';
import { SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
@ -14,8 +21,10 @@ async function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
return res.body as AlbumResponseDto;
}
async function _createAlbumSharedLink(app: INestApplication, data: CreateAlbumShareLinkDto) {
const res = await request(app.getHttpServer()).post('/album/create-shared-link').send(data);
async function _createAlbumSharedLink(app: INestApplication, data: Omit<SharedLinkCreateDto, 'type'>) {
const res = await request(app.getHttpServer())
.post('/shared-link')
.send({ ...data, type: SharedLinkType.ALBUM });
expect(res.status).toEqual(201);
return res.body as SharedLinkResponseDto;
}

View File

@ -127,48 +127,6 @@
]
}
},
"/album/create-shared-link": {
"post": {
"operationId": "createAlbumSharedLink",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateAlbumShareLinkDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/album/{id}": {
"patch": {
"operationId": "updateAlbumInfo",
@ -1660,150 +1618,6 @@
]
}
},
"/asset/shared-link": {
"post": {
"operationId": "createAssetsSharedLink",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateAssetsShareLinkDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/shared-link/add": {
"patch": {
"operationId": "addAssetsToSharedLink",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddAssetsDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/shared-link/remove": {
"patch": {
"operationId": "removeAssetsFromSharedLink",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RemoveAssetsDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/stat/archive": {
"get": {
"operationId": "getArchivedAssetCountByUserId",
@ -3264,7 +3078,7 @@
]
}
},
"/share": {
"/shared-link": {
"get": {
"operationId": "getAllSharedLinks",
"parameters": [],
@ -3284,7 +3098,47 @@
}
},
"tags": [
"share"
"Shared Link"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
},
"post": {
"operationId": "createSharedLink",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkCreateDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"Shared Link"
],
"security": [
{
@ -3299,7 +3153,7 @@
]
}
},
"/share/me": {
"/shared-link/me": {
"get": {
"operationId": "getMySharedLink",
"parameters": [
@ -3325,7 +3179,7 @@
}
},
"tags": [
"share"
"Shared Link"
],
"security": [
{
@ -3340,7 +3194,7 @@
]
}
},
"/share/{id}": {
"/shared-link/{id}": {
"get": {
"operationId": "getSharedLinkById",
"parameters": [
@ -3367,7 +3221,7 @@
}
},
"tags": [
"share"
"Shared Link"
],
"security": [
{
@ -3399,7 +3253,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditSharedLinkDto"
"$ref": "#/components/schemas/SharedLinkEditDto"
}
}
}
@ -3417,7 +3271,7 @@
}
},
"tags": [
"share"
"Shared Link"
],
"security": [
{
@ -3450,7 +3304,131 @@
}
},
"tags": [
"share"
"Shared Link"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/shared-link/{id}/assets": {
"put": {
"operationId": "addSharedLinkAssets",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetIdsDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetIdsResponseDto"
}
}
}
}
}
},
"tags": [
"Shared Link"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
},
"delete": {
"operationId": "removeSharedLinkAssets",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetIdsDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetIdsResponseDto"
}
}
}
}
}
},
"tags": [
"Shared Link"
],
"security": [
{
@ -5089,34 +5067,6 @@
"albumName"
]
},
"CreateAlbumShareLinkDto": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"format": "date-time",
"type": "string"
},
"allowUpload": {
"type": "boolean"
},
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
},
"description": {
"type": "string"
}
},
"required": [
"albumId"
]
},
"CreateAssetDto": {
"type": "object",
"properties": {
@ -5176,42 +5126,6 @@
"fileExtension"
]
},
"CreateAssetsShareLinkDto": {
"type": "object",
"properties": {
"assetIds": {
"title": "Array asset IDs to be shared",
"example": [
"bf973405-3f2a-48d2-a687-2ed4167164be",
"dd41870b-5d00-46d2-924e-1d8489a0aa0f",
"fad77c3f-deef-4e7e-9608-14c1aa4e559a"
],
"type": "array",
"items": {
"type": "string"
}
},
"expiresAt": {
"format": "date-time",
"type": "string"
},
"allowUpload": {
"type": "boolean"
},
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
},
"description": {
"type": "string"
}
},
"required": [
"assetIds"
]
},
"CreateProfileImageDto": {
"type": "object",
"properties": {
@ -5392,28 +5306,6 @@
"assetIds"
]
},
"EditSharedLinkDto": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"expiresAt": {
"format": "date-time",
"type": "string",
"nullable": true
},
"allowUpload": {
"type": "boolean"
},
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
}
}
},
"ExifResponseDto": {
"type": "object",
"properties": {
@ -6160,6 +6052,71 @@
"patch"
]
},
"SharedLinkCreateDto": {
"type": "object",
"properties": {
"type": {
"$ref": "#/components/schemas/SharedLinkType"
},
"assetIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"albumId": {
"type": "string",
"format": "uuid"
},
"description": {
"type": "string"
},
"expiresAt": {
"format": "date-time",
"type": "string",
"nullable": true,
"default": null
},
"allowUpload": {
"type": "boolean",
"default": false
},
"allowDownload": {
"type": "boolean",
"default": true
},
"showExif": {
"type": "boolean",
"default": true
}
},
"required": [
"type"
]
},
"SharedLinkEditDto": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"expiresAt": {
"format": "date-time",
"type": "string",
"nullable": true
},
"allowUpload": {
"type": "boolean"
},
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
}
}
},
"SharedLinkResponseDto": {
"type": "object",
"properties": {
@ -6170,7 +6127,8 @@
"type": "string"
},
"description": {
"type": "string"
"type": "string",
"nullable": true
},
"userId": {
"type": "string"
@ -6209,6 +6167,7 @@
"required": [
"type",
"id",
"description",
"userId",
"key",
"createdAt",

View File

@ -2,8 +2,11 @@ export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
}

View File

@ -13,7 +13,7 @@ import { IKeyRepository } from '../api-key';
import { APIKeyCore } from '../api-key/api-key.core';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { OAuthCore } from '../oauth/oauth.core';
import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
import { ISharedLinkRepository } from '../shared-link';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore } from '../user';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
@ -35,7 +35,6 @@ export class AuthService {
private authCore: AuthCore;
private oauthCore: OAuthCore;
private userCore: UserCore;
private shareCore: SharedLinkCore;
private keyCore: APIKeyCore;
private logger = new Logger(AuthService.name);
@ -45,7 +44,7 @@ export class AuthService {
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
@Inject(ISharedLinkRepository) shareRepository: ISharedLinkRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) keyRepository: IKeyRepository,
@Inject(INITIAL_SYSTEM_CONFIG)
initialConfig: SystemConfig,
@ -54,7 +53,6 @@ export class AuthService {
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
this.oauthCore = new OAuthCore(configRepository, initialConfig);
this.userCore = new UserCore(userRepository, cryptoRepository);
this.shareCore = new SharedLinkCore(shareRepository, cryptoRepository);
this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
}
@ -147,7 +145,7 @@ export class AuthService {
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
if (shareKey) {
return this.shareCore.validate(shareKey);
return this.validateSharedLink(shareKey);
}
if (userToken) {
@ -193,4 +191,29 @@ export class AuthService {
const cookies = cookieParser.parse(headers.cookie || '');
return cookies[IMMICH_ACCESS_COOKIE] || null;
}
async validateSharedLink(key: string | string[]): Promise<AuthUserDto | null> {
key = Array.isArray(key) ? key[0] : key;
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
const link = await this.sharedLinkRepository.getByKey(bytes);
if (link) {
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
const user = link.user;
if (user) {
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: true,
sharedLinkId: link.id,
isAllowUpload: link.allowUpload,
isAllowDownload: link.allowDownload,
isShowExif: link.showExif,
};
}
}
}
throw new UnauthorizedException('Invalid share key');
}
}

View File

@ -1,12 +0,0 @@
import { AlbumEntity, AssetEntity, SharedLinkType } from '@app/infra/entities';
export class CreateSharedLinkDto {
description?: string;
expiresAt?: Date;
type!: SharedLinkType;
assets!: AssetEntity[];
album?: AlbumEntity;
allowUpload?: boolean;
allowDownload?: boolean;
showExif?: boolean;
}

View File

@ -1,18 +0,0 @@
import { IsOptional } from 'class-validator';
export class EditSharedLinkDto {
@IsOptional()
description?: string;
@IsOptional()
expiresAt?: Date | null;
@IsOptional()
allowUpload?: boolean;
@IsOptional()
allowDownload?: boolean;
@IsOptional()
showExif?: boolean;
}

View File

@ -1,2 +0,0 @@
export * from './create-shared-link.dto';
export * from './edit-shared-link.dto';

View File

@ -1,5 +1,4 @@
export * from './dto';
export * from './response-dto';
export * from './shared-link.core';
export * from './shared-link-response.dto';
export * from './shared-link.dto';
export * from './shared-link.repository';
export * from './shared-link.service';

View File

@ -1 +0,0 @@
export * from './shared-link-response.dto';

View File

@ -1,12 +1,12 @@
import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import _ from 'lodash';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../album';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset';
export class SharedLinkResponseDto {
id!: string;
description?: string;
description!: string | null;
userId!: string;
key!: string;

View File

@ -1,80 +0,0 @@
import { AssetEntity, SharedLinkEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { CreateSharedLinkDto } from './dto';
import { ISharedLinkRepository } from './shared-link.repository';
export class SharedLinkCore {
readonly logger = new Logger(SharedLinkCore.name);
constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
// TODO: move to SharedLinkController/SharedLinkService
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
description: dto.description,
userId,
createdAt: new Date(),
expiresAt: dto.expiresAt ?? null,
type: dto.type,
assets: dto.assets,
album: dto.album,
allowUpload: dto.allowUpload ?? false,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
}
async addAssets(userId: string, id: string, assets: AssetEntity[]) {
const link = await this.repository.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.update({ ...link, assets: [...link.assets, ...assets] });
}
async removeAssets(userId: string, id: string, assets: AssetEntity[]) {
const link = await this.repository.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
const newAssets = link.assets.filter((asset) => assets.find((a) => a.id === asset.id));
return this.repository.update({ ...link, assets: newAssets });
}
checkDownloadAccess(user: AuthUserDto) {
if (user.isPublicUser && !user.isAllowDownload) {
throw new ForbiddenException();
}
}
async validate(key: string | string[]): Promise<AuthUserDto | null> {
key = Array.isArray(key) ? key[0] : key;
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
const link = await this.repository.getByKey(bytes);
if (link) {
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
const user = link.user;
if (user) {
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: true,
sharedLinkId: link.id,
isAllowUpload: link.allowUpload,
isAllowDownload: link.allowDownload,
isShowExif: link.showExif,
};
}
}
}
throw new UnauthorizedException('Invalid share key');
}
}

View File

@ -0,0 +1,53 @@
import { SharedLinkType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsDate, IsEnum, IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../../immich/decorators/validate-uuid.decorator';
export class SharedLinkCreateDto {
@IsEnum(SharedLinkType)
@ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' })
type!: SharedLinkType;
@ValidateUUID({ each: true, optional: true })
assetIds?: string[];
@ValidateUUID({ optional: true })
albumId?: string;
@IsString()
@IsOptional()
description?: string;
@IsDate()
@IsOptional()
expiresAt?: Date | null = null;
@IsOptional()
@IsBoolean()
allowUpload?: boolean = false;
@IsOptional()
@IsBoolean()
allowDownload?: boolean = true;
@IsOptional()
@IsBoolean()
showExif?: boolean = true;
}
export class SharedLinkEditDto {
@IsOptional()
description?: string;
@IsOptional()
expiresAt?: Date | null;
@IsOptional()
allowUpload?: boolean;
@IsOptional()
allowDownload?: boolean;
@IsOptional()
showExif?: boolean;
}

View File

@ -6,7 +6,7 @@ export interface ISharedLinkRepository {
getAll(userId: string): Promise<SharedLinkEntity[]>;
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
getByKey(key: Buffer): Promise<SharedLinkEntity | null>;
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<void>;
}

View File

@ -1,16 +1,33 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { authStub, newSharedLinkRepositoryMock, sharedLinkResponseStub, sharedLinkStub } from '@test';
import {
albumStub,
assetEntityStub,
authStub,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newSharedLinkRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@test';
import { when } from 'jest-when';
import _ from 'lodash';
import { SharedLinkType } from '../../infra/entities/shared-link.entity';
import { AssetIdErrorReason, IAccessRepository, ICryptoRepository } from '../index';
import { ISharedLinkRepository } from './shared-link.repository';
import { SharedLinkService } from './shared-link.service';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let accessMock: jest.Mocked<IAccessRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>;
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
sut = new SharedLinkService(shareMock);
sut = new SharedLinkService(accessMock, cryptoMock, shareMock);
});
it('should work', () => {
@ -64,6 +81,82 @@ describe(SharedLinkService.name, () => {
});
});
describe('create', () => {
it('should not allow an album shared link without an albumId', async () => {
await expect(sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should not allow non-owners to create album shared links', async () => {
accessMock.hasAlbumOwnerAccess.mockResolvedValue(false);
await expect(
sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow individual shared links with no assets', async () => {
await expect(
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: [] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require asset ownership to make an individual shared link', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
await expect(
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create an album shared link', async () => {
accessMock.hasAlbumOwnerAccess.mockResolvedValue(true);
shareMock.create.mockResolvedValue(sharedLinkStub.valid);
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
expect(accessMock.hasAlbumOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.ALBUM,
userId: authStub.admin.id,
albumId: albumStub.oneAsset.id,
allowDownload: true,
allowUpload: true,
assets: [],
description: null,
expiresAt: null,
showExif: true,
key: Buffer.from('random-bytes', 'utf8'),
});
});
it('should create an individual shared link', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [assetEntityStub.image.id],
showExif: true,
allowDownload: true,
allowUpload: true,
});
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.id,
albumId: null,
allowDownload: true,
allowUpload: true,
assets: [{ id: assetEntityStub.image.id }],
description: null,
expiresAt: null,
showExif: true,
key: Buffer.from('random-bytes', 'utf8'),
});
});
});
describe('update', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
@ -100,4 +193,58 @@ describe(SharedLinkService.name, () => {
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
});
});
describe('addAssets', () => {
it('should not work on album shared links', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should add assets to a shared link', async () => {
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
await expect(
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2', 'asset-3'] }),
).resolves.toEqual([
{ assetId: assetEntityStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION },
{ assetId: 'asset-3', success: true },
]);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledTimes(2);
expect(shareMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual,
assets: [assetEntityStub.image, { id: 'asset-3' }],
});
});
});
describe('removeAssets', () => {
it('should not work on album shared links', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should remove assets from a shared link', async () => {
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await expect(
sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2'] }),
).resolves.toEqual([
{ assetId: assetEntityStub.image.id, success: true },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
]);
expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
});
});
});

View File

@ -1,15 +1,22 @@
import { SharedLinkEntity } from '@app/infra/entities';
import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { IAccessRepository } from '../access';
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
import { AuthUserDto } from '../auth';
import { EditSharedLinkDto } from './dto';
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
import { ICryptoRepository } from '../crypto';
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './shared-link-response.dto';
import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
import { ISharedLinkRepository } from './shared-link.repository';
@Injectable()
export class SharedLinkService {
constructor(@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository) {}
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
) {}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
}
@ -30,7 +37,52 @@ export class SharedLinkService {
return this.map(sharedLink, { withExif: true });
}
async update(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
async create(authUser: AuthUserDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
switch (dto.type) {
case SharedLinkType.ALBUM:
if (!dto.albumId) {
throw new BadRequestException('Invalid albumId');
}
const isAlbumOwner = await this.accessRepository.hasAlbumOwnerAccess(authUser.id, dto.albumId);
if (!isAlbumOwner) {
throw new BadRequestException('Invalid albumId');
}
break;
case SharedLinkType.INDIVIDUAL:
if (!dto.assetIds || dto.assetIds.length === 0) {
throw new BadRequestException('Invalid assetIds');
}
for (const assetId of dto.assetIds) {
const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
if (!hasAccess) {
throw new BadRequestException(`No access to assetId: ${assetId}`);
}
}
break;
}
const sharedLink = await this.repository.create({
key: this.cryptoRepository.randomBytes(50),
userId: authUser.id,
type: dto.type,
albumId: dto.albumId || null,
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
description: dto.description || null,
expiresAt: dto.expiresAt || null,
allowUpload: dto.allowUpload ?? true,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
return this.map(sharedLink, { withExif: true });
}
async update(authUser: AuthUserDto, id: string, dto: SharedLinkEditDto) {
await this.findOrFail(authUser, id);
const sharedLink = await this.repository.update({
id,
@ -57,6 +109,60 @@ export class SharedLinkService {
return sharedLink;
}
async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
const sharedLink = await this.findOrFail(authUser, id);
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
throw new BadRequestException('Invalid shared link type');
}
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
if (hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
if (!hasAccess) {
results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ assetId, success: true });
sharedLink.assets.push({ id: assetId } as AssetEntity);
}
await this.repository.update(sharedLink);
return results;
}
async removeAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
const sharedLink = await this.findOrFail(authUser, id);
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
throw new BadRequestException('Invalid shared link type');
}
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
if (!hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
continue;
}
results.push({ assetId, success: true });
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId);
}
await this.repository.update(sharedLink);
return results;
}
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
}

View File

@ -1,5 +1,5 @@
import { AlbumResponseDto } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Response } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { handleDownload } from '../../app.utils';
@ -10,7 +10,6 @@ import { UseValidation } from '../../decorators/use-validation.decorator';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { AlbumService } from './album.service';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ -59,9 +58,4 @@ export class AlbumController {
) {
return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res));
}
@Post('create-shared-link')
createAlbumSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumSharedLinkDto) {
return this.service.createSharedLink(authUser, dto);
}
}

View File

@ -1,7 +1,7 @@
import { AlbumResponseDto, ICryptoRepository, ISharedLinkRepository, mapUser } from '@app/domain';
import { AlbumResponseDto, mapUser } from '@app/domain';
import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { newCryptoRepositoryMock, newSharedLinkRepositoryMock, userEntityStub } from '@test';
import { userEntityStub } from '@test';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { IAlbumRepository } from './album-repository';
@ -11,9 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
@ -99,20 +97,11 @@ describe('Album service', () => {
updateThumbnails: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
downloadServiceMock = {
downloadArchive: jest.fn(),
};
cryptoMock = newCryptoRepositoryMock();
sut = new AlbumService(
albumRepositoryMock,
sharedLinkRepositoryMock,
downloadServiceMock as DownloadService,
cryptoMock,
);
sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService);
});
it('gets an owned album', async () => {

View File

@ -1,36 +1,22 @@
import {
AlbumResponseDto,
ICryptoRepository,
ISharedLinkRepository,
mapAlbum,
mapSharedLink,
SharedLinkCore,
SharedLinkResponseDto,
} from '@app/domain';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { AlbumEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { IAlbumRepository } from './album-repository';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@Injectable()
export class AlbumService {
readonly logger = new Logger(AlbumService.name);
private shareCore: SharedLinkCore;
private logger = new Logger(AlbumService.name);
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
) {
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
}
) {}
private async _getAlbum({
authUser,
@ -91,7 +77,7 @@ export class AlbumService {
}
async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
this.shareCore.checkDownloadAccess(authUser);
this.checkDownloadAccess(authUser);
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0);
@ -99,20 +85,9 @@ export class AlbumService {
return this.downloadService.downloadArchive(album.albumName, assets);
}
async createSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
const album = await this._getAlbum({ authUser, albumId: dto.albumId });
const sharedLink = await this.shareCore.create(authUser.id, {
type: SharedLinkType.ALBUM,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
album,
assets: [],
description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
});
return mapSharedLink(sharedLink);
private checkDownloadAccess(authUser: AuthUserDto) {
if (authUser.isPublicUser && !authUser.isAllowDownload) {
throw new ForbiddenException();
}
}
}

View File

@ -1,35 +0,0 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator';
export class CreateAlbumShareLinkDto {
@ValidateUUID()
albumId!: string;
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiProperty()
expiresAt?: Date;
@IsBoolean()
@IsOptional()
@ApiProperty()
allowUpload?: boolean;
@IsBoolean()
@IsOptional()
@ApiProperty()
allowDownload?: boolean;
@IsBoolean()
@IsOptional()
@ApiProperty()
showExif?: boolean;
@IsString()
@IsOptional()
@ApiProperty()
description?: string;
}

View File

@ -1,4 +1,4 @@
import { AssetResponseDto, ImmichReadStream, SharedLinkResponseDto } from '@app/domain';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import {
Body,
Controller,
@ -10,7 +10,6 @@ import {
HttpStatus,
Param,
ParseFilePipe,
Patch,
Post,
Put,
Query,
@ -28,15 +27,12 @@ import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { AssetService } from './asset.service';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto';
@ -319,30 +315,4 @@ export class AssetController {
): Promise<AssetBulkUploadCheckResponseDto> {
return this.assetService.bulkUploadCheck(authUser, dto);
}
@Post('/shared-link')
createAssetsSharedLink(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CreateAssetsShareLinkDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.createAssetsSharedLink(authUser, dto);
}
@SharedLinkRoute()
@Patch('/shared-link/add')
addAssetsToSharedLink(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AddAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.addAssetsToSharedLink(authUser, dto);
}
@SharedLinkRoute()
@Patch('/shared-link/remove')
removeAssetsFromSharedLink(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: RemoveAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.removeAssetsFromSharedLink(authUser, dto);
}
}

View File

@ -1,31 +1,19 @@
import {
IAccessRepository,
ICryptoRepository,
IJobRepository,
ISharedLinkRepository,
IStorageRepository,
JobName,
} from '@app/domain';
import { IAccessRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { ForbiddenException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
fileStub,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
newStorageRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError, Repository } from 'typeorm';
import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
@ -134,8 +122,6 @@ describe('AssetService', () => {
let accessMock: jest.Mocked<IAccessRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
@ -165,9 +151,7 @@ describe('AssetService', () => {
};
accessMock = newAccessRepositoryMock();
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(
@ -175,9 +159,7 @@ describe('AssetService', () => {
assetRepositoryMock,
a,
downloadServiceMock as DownloadService,
sharedLinkRepositoryMock,
jobMock,
cryptoMock,
storageMock,
);
@ -189,77 +171,6 @@ describe('AssetService', () => {
.mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
});
describe('createAssetsSharedLink', () => {
it('should create an individual share link', async () => {
const asset1 = _getAsset_1();
const dto: CreateAssetsShareLinkDto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.user1.id, asset1.id);
});
});
describe('updateAssetsInSharedLink', () => {
it('should require a valid shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(null);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
await expect(sut.addAssetsToSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.update).not.toHaveBeenCalled();
});
it('should add assets to a shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssetsToSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
});
it('should remove assets from a shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssetsFromSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
});
});
describe('uploadFile', () => {
it('should handle a file upload', async () => {
const assetEntity = _getAsset_1();

View File

@ -2,19 +2,14 @@ import {
AssetResponseDto,
getLivePhotoMotionFilename,
IAccessRepository,
ICryptoRepository,
IJobRepository,
ImmichReadStream,
ISharedLinkRepository,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
mapSharedLink,
SharedLinkCore,
SharedLinkResponseDto,
} from '@app/domain';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
import { AssetEntity, AssetType } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
@ -33,15 +28,12 @@ import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
@ -80,22 +72,17 @@ interface ServableFile {
@Injectable()
export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: SharedLinkCore;
private assetCore: AssetCore;
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
private downloadService: DownloadService,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
}
public async uploadFile(
@ -608,61 +595,9 @@ export class AssetService {
}
private checkDownloadAccess(authUser: AuthUserDto) {
this.shareCore.checkDownloadAccess(authUser);
}
async createAssetsSharedLink(authUser: AuthUserDto, dto: CreateAssetsShareLinkDto): Promise<SharedLinkResponseDto> {
const assets = [];
await this.checkAssetsAccess(authUser, dto.assetIds);
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const sharedLink = await this.shareCore.create(authUser.id, {
type: SharedLinkType.INDIVIDUAL,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
assets,
description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
});
return mapSharedLink(sharedLink);
}
async addAssetsToSharedLink(authUser: AuthUserDto, dto: AddAssetsDto): Promise<SharedLinkResponseDto> {
if (!authUser.sharedLinkId) {
if (authUser.isPublicUser && !authUser.isAllowDownload) {
throw new ForbiddenException();
}
const assets = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const updatedLink = await this.shareCore.addAssets(authUser.id, authUser.sharedLinkId, assets);
return mapSharedLink(updatedLink);
}
async removeAssetsFromSharedLink(authUser: AuthUserDto, dto: RemoveAssetsDto): Promise<SharedLinkResponseDto> {
if (!authUser.sharedLinkId) {
throw new ForbiddenException();
}
const assets = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const updatedLink = await this.shareCore.removeAssets(authUser.id, authUser.sharedLinkId, assets);
return mapSharedLink(updatedLink);
}
getExifPermission(authUser: AuthUserDto) {

View File

@ -1,41 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAssetsShareLinkDto {
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ApiProperty({
isArray: true,
type: String,
title: 'Array asset IDs to be shared',
example: [
'bf973405-3f2a-48d2-a687-2ed4167164be',
'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
],
})
assetIds!: string[];
@IsDate()
@Type(() => Date)
@IsOptional()
expiresAt?: Date;
@IsBoolean()
@IsOptional()
allowUpload?: boolean;
@IsBoolean()
@IsOptional()
allowDownload?: boolean;
@IsBoolean()
@IsOptional()
showExif?: boolean;
@IsString()
@IsOptional()
description?: string;
}

View File

@ -1,13 +1,21 @@
import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, SharedLinkService } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch } from '@nestjs/common';
import {
AssetIdsDto,
AssetIdsResponseDto,
AuthUserDto,
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkResponseDto,
SharedLinkService,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser } from '../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('share')
@Controller('share')
@ApiTags('Shared Link')
@Controller('shared-link')
@Authenticated()
@UseValidation()
export class SharedLinkController {
@ -29,11 +37,16 @@ export class SharedLinkController {
return this.service.get(authUser, id);
}
@Post()
createSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: SharedLinkCreateDto) {
return this.service.create(authUser, dto);
}
@Patch(':id')
updateSharedLink(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: EditSharedLinkDto,
@Body() dto: SharedLinkEditDto,
): Promise<SharedLinkResponseDto> {
return this.service.update(authUser, id, dto);
}
@ -42,4 +55,24 @@ export class SharedLinkController {
removeSharedLink(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
@SharedLinkRoute()
@Put(':id/assets')
addSharedLinkAssets(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
return this.service.addAssets(authUser, id, dto);
}
@SharedLinkRoute()
@Delete(':id/assets')
removeSharedLinkAssets(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
return this.service.removeAssets(authUser, id, dto);
}
}

View File

@ -18,8 +18,8 @@ export class SharedLinkEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ nullable: true })
description?: string;
@Column({ type: 'varchar', nullable: true })
description!: string | null;
@Column()
userId!: string;
@ -55,6 +55,9 @@ export class SharedLinkEntity {
@Index('IDX_sharedlink_albumId')
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
album?: AlbumEntity;
@Column({ type: 'varchar', nullable: true })
albumId!: string | null;
}
export enum SharedLinkType {

View File

@ -95,4 +95,13 @@ export class AccessRepository implements IAccessRepository {
}))
);
}
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean> {
return this.albumRepository.exist({
where: {
id: albumId,
ownerId: userId,
},
});
}
}

View File

@ -777,6 +777,21 @@ export const loginResponseStub = {
};
export const sharedLinkStub = {
individual: Object.freeze({
id: '123',
userId: authStub.admin.id,
user: userEntityStub.admin,
key: sharedLinkBytes,
type: SharedLinkType.INDIVIDUAL,
createdAt: today,
expiresAt: tomorrow,
allowUpload: true,
allowDownload: true,
showExif: true,
album: undefined,
description: null,
assets: [assetEntityStub.image],
} as SharedLinkEntity),
valid: Object.freeze({
id: '123',
userId: authStub.admin.id,
@ -789,6 +804,8 @@ export const sharedLinkStub = {
allowDownload: true,
showExif: true,
album: undefined,
albumId: null,
description: null,
assets: [],
} as SharedLinkEntity),
expired: Object.freeze({
@ -802,6 +819,8 @@ export const sharedLinkStub = {
allowUpload: true,
allowDownload: true,
showExif: true,
description: null,
albumId: null,
assets: [],
} as SharedLinkEntity),
readonlyNoExif: Object.freeze<SharedLinkEntity>({
@ -815,7 +834,9 @@ export const sharedLinkStub = {
allowUpload: false,
allowDownload: false,
showExif: false,
description: null,
assets: [],
albumId: 'album-123',
album: {
id: 'album-123',
ownerId: authStub.admin.id,
@ -903,7 +924,7 @@ export const sharedLinkResponseStub = {
allowUpload: true,
assets: [],
createdAt: today,
description: undefined,
description: null,
expiresAt: tomorrow,
id: '123',
key: sharedLinkBytes.toString('base64url'),
@ -917,7 +938,7 @@ export const sharedLinkResponseStub = {
allowUpload: true,
assets: [],
createdAt: today,
description: undefined,
description: null,
expiresAt: yesterday,
id: '123',
key: sharedLinkBytes.toString('base64url'),
@ -932,7 +953,7 @@ export const sharedLinkResponseStub = {
type: SharedLinkType.ALBUM,
createdAt: today,
expiresAt: tomorrow,
description: undefined,
description: null,
allowUpload: false,
allowDownload: false,
showExif: true,
@ -946,7 +967,7 @@ export const sharedLinkResponseStub = {
type: SharedLinkType.ALBUM,
createdAt: today,
expiresAt: tomorrow,
description: undefined,
description: null,
allowUpload: false,
allowDownload: false,
showExif: false,

View File

@ -3,9 +3,12 @@ import { IAccessRepository } from '@app/domain';
export const newAccessRepositoryMock = (): jest.Mocked<IAccessRepository> => {
return {
hasPartnerAccess: jest.fn(),
hasAlbumAssetAccess: jest.fn(),
hasOwnerAssetAccess: jest.fn(),
hasPartnerAssetAccess: jest.fn(),
hasSharedLinkAssetAccess: jest.fn(),
hasAlbumOwnerAccess: jest.fn(),
};
};

View File

@ -7,16 +7,16 @@ import {
Configuration,
ConfigurationParameters,
JobApi,
JobName,
OAuthApi,
PersonApi,
PartnerApi,
PersonApi,
SearchApi,
ServerInfoApi,
ShareApi,
SharedLinkApi,
SystemConfigApi,
UserApi,
UserApiFp,
JobName
UserApiFp
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
@ -32,7 +32,7 @@ export class ImmichApi {
public partnerApi: PartnerApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public shareApi: ShareApi;
public sharedLinkApi: SharedLinkApi;
public personApi: PersonApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
@ -51,7 +51,7 @@ export class ImmichApi {
this.partnerApi = new PartnerApi(this.config);
this.searchApi = new SearchApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.shareApi = new ShareApi(this.config);
this.sharedLinkApi = new SharedLinkApi(this.config);
this.personApi = new PersonApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);

File diff suppressed because it is too large Load Diff

View File

@ -31,7 +31,7 @@
});
const getSharedLinks = async () => {
const { data } = await api.shareApi.getAllSharedLinks();
const { data } = await api.sharedLinkApi.getAllSharedLinks();
sharedLinks = data.filter((link) => link.album?.id === album.id);
};

View File

@ -1,34 +1,65 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { AssetResponseDto, SharedLinkResponseDto, api } from '@api';
import { SharedLinkResponseDto, api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import {
NotificationType,
notificationController
} from '../../shared-components/notification/notification';
import { handleError } from '../../../utils/handle-error';
export let sharedLink: SharedLinkResponseDto;
export let allAssets: AssetResponseDto[];
let removing = false;
const { getAssets, clearSelect } = getAssetControlContext();
const handleRemoveAssetsFromSharedLink = async () => {
if (window.confirm('Do you want to remove selected assets from the shared link?')) {
// TODO: Rename API method or change functionality. The assetIds passed
// in are kept instead of removed.
const assetsToKeep = allAssets.filter((a) => !getAssets().has(a));
await api.assetApi.removeAssetsFromSharedLink({
removeAssetsDto: {
assetIds: assetsToKeep.map((a) => a.id)
const handleRemove = async () => {
try {
const { data: results } = await api.sharedLinkApi.removeSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: {
assetIds: Array.from(getAssets()).map((asset) => asset.id)
},
key: sharedLink?.key
key: sharedLink.key
});
for (const result of results) {
if (!result.success) {
continue;
}
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId);
}
const count = results.filter((item) => item.success).length;
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} assets`
});
sharedLink.assets = assetsToKeep;
clearSelect();
} catch (error) {
handleError(error, 'Unable to remove assets from shared link');
}
};
</script>
<CircleIconButton
title="Remove from album"
on:click={handleRemoveAssetsFromSharedLink}
title="Remove from shared link"
on:click={() => (removing = true)}
logo={DeleteOutline}
/>
{#if removing}
<ConfirmDialogue
title="Remove Assets?"
prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?"
confirmText="Remove"
on:confirm={() => handleRemove()}
on:cancel={() => (removing = false)}
/>
{/if}

View File

@ -17,6 +17,7 @@
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import { handleError } from '../../utils/handle-error';
export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean;
@ -26,43 +27,40 @@
$: assets = sharedLink.assets;
$: isMultiSelectionMode = selectedAssets.size > 0;
const clearMultiSelectAssetAssetHandler = () => {
selectedAssets = new Set();
};
const downloadAssets = async () => {
await bulkDownload('immich-shared', assets, undefined, sharedLink?.key);
await bulkDownload('immich-shared', assets, undefined, sharedLink.key);
};
const handleUploadAssets = async () => {
try {
const results = await openFileUploadDialog(undefined, sharedLink?.key);
const results = await openFileUploadDialog(undefined, sharedLink.key);
const assetIds = results.filter((id) => !!id) as string[];
await api.assetApi.addAssetsToSharedLink({
addAssetsDto: {
assetIds
const { data } = await api.sharedLinkApi.addSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: {
assetIds: results.filter((id) => !!id) as string[]
},
key: sharedLink?.key
key: sharedLink.key
});
const added = data.filter((item) => item.success).length;
notificationController.show({
message: `Successfully add ${assetIds.length} to the shared link`,
message: `Added ${added} assets`,
type: NotificationType.Info
});
} catch (e) {
console.error('handleUploadAssets', e);
handleError(e, 'Unable to add assets to shared link');
}
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg">
{#if isMultiSelectionMode}
<AssetSelectControlBar assets={selectedAssets} clearSelect={clearMultiSelectAssetAssetHandler}>
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<DownloadAction filename="immich-shared" sharedLinkKey={sharedLink.key} />
{#if isOwned}
<RemoveFromSharedLink bind:sharedLink allAssets={assets} />
<RemoveFromSharedLink bind:sharedLink />
{/if}
</AssetSelectControlBar>
{:else}

View File

@ -7,31 +7,31 @@
import { handleError } from '$lib/utils/handle-error';
import {
AlbumResponseDto,
api,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType,
api
SharedLinkType
} from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import Link from 'svelte-material-icons/Link.svelte';
import BaseModal from '../base-modal.svelte';
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
import DropdownButton from '../dropdown-button.svelte';
import { NotificationType, notificationController } from '../notification/notification';
import { notificationController, NotificationType } from '../notification/notification';
export let shareType: SharedLinkType;
export let sharedAssets: AssetResponseDto[] = [];
export let album: AlbumResponseDto | undefined = undefined;
export let editingLink: SharedLinkResponseDto | undefined = undefined;
let isShowSharedLink = false;
let expirationTime = '';
let isAllowUpload = false;
let sharedLink = '';
let sharedLink: string | null = null;
let description = '';
let allowDownload = true;
let allowUpload = false;
let showExif = true;
let expirationTime = '';
let shouldChangeExpirationTime = false;
let isAllowDownload = true;
let shouldShowExif = true;
const dispatch = createEventDispatcher();
const expiredDateOption: ImmichDropDownOption = {
@ -44,9 +44,9 @@
if (editingLink.description) {
description = editingLink.description;
}
isAllowUpload = editingLink.allowUpload;
isAllowDownload = editingLink.allowDownload;
shouldShowExif = editingLink.showExif;
allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload;
showExif = editingLink.showExif;
}
});
@ -58,49 +58,32 @@
: undefined;
try {
if (shareType === SharedLinkType.Album && album) {
const { data } = await api.albumApi.createAlbumSharedLink({
createAlbumShareLinkDto: {
albumId: album.id,
expiresAt: expirationDate,
allowUpload: isAllowUpload,
description: description,
allowDownload: isAllowDownload,
showExif: shouldShowExif
}
});
buildSharedLink(data);
} else {
const { data } = await api.assetApi.createAssetsSharedLink({
createAssetsShareLinkDto: {
assetIds: sharedAssets.map((a) => a.id),
expiresAt: expirationDate,
allowUpload: isAllowUpload,
description: description,
allowDownload: isAllowDownload,
showExif: shouldShowExif
}
});
buildSharedLink(data);
}
const { data } = await api.sharedLinkApi.createSharedLink({
sharedLinkCreateDto: {
type: shareType,
albumId: album ? album.id : undefined,
assetIds: sharedAssets.map((a) => a.id),
expiresAt: expirationDate,
allowUpload,
description,
allowDownload,
showExif
}
});
sharedLink = `${window.location.origin}/share/${data.key}`;
} catch (e) {
handleError(e, 'Failed to create shared link');
}
isShowSharedLink = true;
};
const buildSharedLink = (createdLink: SharedLinkResponseDto) => {
sharedLink = `${window.location.origin}/share/${createdLink.key}`;
};
const handleCopy = async () => {
if (!sharedLink) {
return;
}
try {
await navigator.clipboard.writeText(sharedLink);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info
});
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
} catch (e) {
handleError(
e,
@ -129,34 +112,36 @@
};
const handleEditLink = async () => {
if (editingLink) {
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate: string | null = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: null;
if (!editingLink) {
return;
}
await api.shareApi.updateSharedLink({
id: editingLink.id,
editSharedLinkDto: {
description,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload: isAllowUpload,
allowDownload: isAllowDownload,
showExif: shouldShowExif
}
});
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate: string | null = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: null;
notificationController.show({
type: NotificationType.Info,
message: 'Edited'
});
await api.sharedLinkApi.updateSharedLink({
id: editingLink.id,
sharedLinkEditDto: {
description,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload: allowUpload,
allowDownload: allowDownload,
showExif: showExif
}
});
dispatch('close');
} catch (e) {
handleError(e, 'Failed to edit shared link');
}
notificationController.show({
type: NotificationType.Info,
message: 'Edited'
});
dispatch('close');
} catch (e) {
handleError(e, 'Failed to edit shared link');
}
};
</script>
@ -212,15 +197,15 @@
</div>
<div class="my-3">
<SettingSwitch bind:checked={shouldShowExif} title={'Show metadata'} />
<SettingSwitch bind:checked={showExif} title={'Show metadata'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={isAllowDownload} title={'Allow public user to download'} />
<SettingSwitch bind:checked={allowDownload} title={'Allow public user to download'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} />
<SettingSwitch bind:checked={allowUpload} title={'Allow public user to upload'} />
</div>
<div class="text-sm">
@ -248,7 +233,7 @@
<hr />
<section class="m-6">
{#if !isShowSharedLink}
{#if !sharedLink}
{#if editingLink}
<div class="flex justify-end">
<Button size="sm" rounded="lg" on:click={handleEditLink}>Confirm</Button>
@ -258,9 +243,7 @@
<Button size="sm" rounded="lg" on:click={handleCreateSharedLink}>Create link</Button>
</div>
{/if}
{/if}
{#if isShowSharedLink}
{:else}
<div class="flex w-full gap-4">
<input class="immich-form-input w-full" bind:value={sharedLink} disabled />

View File

@ -7,7 +7,7 @@ export const load = (async ({ params, locals: { api } }) => {
const { key } = params;
try {
const { data: sharedLink } = await api.shareApi.getMySharedLink({ key });
const { data: sharedLink } = await api.sharedLinkApi.getMySharedLink({ key });
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;

View File

@ -1,7 +1,6 @@
<script lang="ts">
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import { api, SharedLinkResponseDto } from '@api';
import { goto } from '$app/navigation';
import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte';
@ -11,53 +10,45 @@
} from '$lib/components/shared-components/notification/notification';
import { onMount } from 'svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AppRoute } from '$lib/constants';
let sharedLinks: SharedLinkResponseDto[] = [];
let showEditForm = false;
let editSharedLink: SharedLinkResponseDto;
let editSharedLink: SharedLinkResponseDto | null = null;
onMount(async () => {
sharedLinks = await getSharedLinks();
});
let deleteLinkId: string | null = null;
const getSharedLinks = async () => {
const { data: sharedLinks } = await api.shareApi.getAllSharedLinks();
return sharedLinks;
const refresh = async () => {
const { data } = await api.sharedLinkApi.getAllSharedLinks();
sharedLinks = data;
};
const handleDeleteLink = async (linkId: string) => {
if (window.confirm('Do you want to delete the shared link? ')) {
try {
await api.shareApi.removeSharedLink({ id: linkId });
notificationController.show({
message: 'Shared link deleted',
type: NotificationType.Info
});
onMount(async () => {
await refresh();
});
sharedLinks = await getSharedLinks();
} catch (e) {
console.error(e);
notificationController.show({
message: 'Failed to delete shared link',
type: NotificationType.Error
});
}
const handleDeleteLink = async () => {
if (!deleteLinkId) {
return;
}
try {
await api.sharedLinkApi.removeSharedLink({ id: deleteLinkId });
notificationController.show({ message: 'Deleted shared link', type: NotificationType.Info });
deleteLinkId = null;
refresh();
} catch (error) {
handleError(error, 'Unable to delete shared link');
}
};
const handleEditLink = async (id: string) => {
const { data } = await api.shareApi.getSharedLinkById({ id });
editSharedLink = data;
showEditForm = true;
};
const handleEditDone = async () => {
sharedLinks = await getSharedLinks();
showEditForm = false;
refresh();
editSharedLink = null;
};
const handleCopy = async (key: string) => {
const handleCopyLink = async (key: string) => {
const link = `${window.location.origin}/share/${key}`;
await navigator.clipboard.writeText(link);
notificationController.show({
@ -67,7 +58,7 @@
};
</script>
<ControlAppBar backIcon={ArrowLeft} on:close-button-click={() => goto('/sharing')}>
<ControlAppBar backIcon={ArrowLeft} on:close-button-click={() => goto(AppRoute.SHARING)}>
<svelte:fragment slot="leading">Shared links</svelte:fragment>
</ControlAppBar>
@ -86,16 +77,16 @@
{#each sharedLinks as link (link.id)}
<SharedLinkCard
{link}
on:delete={() => handleDeleteLink(link.id)}
on:edit={() => handleEditLink(link.id)}
on:copy={() => handleCopy(link.key)}
on:delete={() => (deleteLinkId = link.id)}
on:edit={() => (editSharedLink = link)}
on:copy={() => handleCopyLink(link.key)}
/>
{/each}
</div>
{/if}
</section>
{#if showEditForm}
{#if editSharedLink}
<CreateSharedLinkModal
editingLink={editSharedLink}
shareType={editSharedLink.type}
@ -103,3 +94,13 @@
on:close={handleEditDone}
/>
{/if}
{#if deleteLinkId}
<ConfirmDialogue
title="Delete Shared Link"
prompt="Are you want to delete this shared link?"
confirmText="Delete"
on:confirm={() => handleDeleteLink()}
on:cancel={() => (deleteLinkId = null)}
/>
{/if}