1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

feat(web/server) public album sharing (#1266)

This commit is contained in:
Alex 2023-01-09 14:16:08 -06:00 committed by GitHub
parent fd15cdbf40
commit 10789503c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
101 changed files with 3103 additions and 347 deletions

View file

@ -30,6 +30,7 @@ doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md doc/CheckExistingAssetsResponseDto.md
doc/CreateAlbumDto.md doc/CreateAlbumDto.md
doc/CreateAlbumShareLinkDto.md
doc/CreateProfileImageResponseDto.md doc/CreateProfileImageResponseDto.md
doc/CreateTagDto.md doc/CreateTagDto.md
doc/CreateUserDto.md doc/CreateUserDto.md
@ -41,6 +42,8 @@ doc/DeleteAssetStatus.md
doc/DeviceInfoApi.md doc/DeviceInfoApi.md
doc/DeviceInfoResponseDto.md doc/DeviceInfoResponseDto.md
doc/DeviceTypeEnum.md doc/DeviceTypeEnum.md
doc/DownloadFilesDto.md
doc/EditSharedLinkDto.md
doc/ExifResponseDto.md doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md
@ -64,6 +67,9 @@ doc/ServerInfoResponseDto.md
doc/ServerPingResponse.md doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md doc/ServerVersionReponseDto.md
doc/ShareApi.md
doc/SharedLinkResponseDto.md
doc/SharedLinkType.md
doc/SignUpDto.md doc/SignUpDto.md
doc/SmartInfoResponseDto.md doc/SmartInfoResponseDto.md
doc/SystemConfigApi.md doc/SystemConfigApi.md
@ -97,6 +103,7 @@ lib/api/device_info_api.dart
lib/api/job_api.dart lib/api/job_api.dart
lib/api/o_auth_api.dart lib/api/o_auth_api.dart
lib/api/server_info_api.dart lib/api/server_info_api.dart
lib/api/share_api.dart
lib/api/system_config_api.dart lib/api/system_config_api.dart
lib/api/tag_api.dart lib/api/tag_api.dart
lib/api/user_api.dart lib/api/user_api.dart
@ -131,6 +138,7 @@ lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart lib/model/check_existing_assets_response_dto.dart
lib/model/create_album_dto.dart lib/model/create_album_dto.dart
lib/model/create_album_share_link_dto.dart
lib/model/create_profile_image_response_dto.dart lib/model/create_profile_image_response_dto.dart
lib/model/create_tag_dto.dart lib/model/create_tag_dto.dart
lib/model/create_user_dto.dart lib/model/create_user_dto.dart
@ -141,6 +149,8 @@ lib/model/delete_asset_response_dto.dart
lib/model/delete_asset_status.dart lib/model/delete_asset_status.dart
lib/model/device_info_response_dto.dart lib/model/device_info_response_dto.dart
lib/model/device_type_enum.dart lib/model/device_type_enum.dart
lib/model/download_files_dto.dart
lib/model/edit_shared_link_dto.dart
lib/model/exif_response_dto.dart lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart
@ -161,6 +171,8 @@ lib/model/server_info_response_dto.dart
lib/model/server_ping_response.dart lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart lib/model/server_version_reponse_dto.dart
lib/model/shared_link_response_dto.dart
lib/model/shared_link_type.dart
lib/model/sign_up_dto.dart lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart lib/model/smart_info_response_dto.dart
lib/model/system_config_dto.dart lib/model/system_config_dto.dart
@ -209,6 +221,7 @@ test/check_duplicate_asset_response_dto_test.dart
test/check_existing_assets_dto_test.dart test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart test/check_existing_assets_response_dto_test.dart
test/create_album_dto_test.dart test/create_album_dto_test.dart
test/create_album_share_link_dto_test.dart
test/create_profile_image_response_dto_test.dart test/create_profile_image_response_dto_test.dart
test/create_tag_dto_test.dart test/create_tag_dto_test.dart
test/create_user_dto_test.dart test/create_user_dto_test.dart
@ -220,6 +233,8 @@ test/delete_asset_status_test.dart
test/device_info_api_test.dart test/device_info_api_test.dart
test/device_info_response_dto_test.dart test/device_info_response_dto_test.dart
test/device_type_enum_test.dart test/device_type_enum_test.dart
test/download_files_dto_test.dart
test/edit_shared_link_dto_test.dart
test/exif_response_dto_test.dart test/exif_response_dto_test.dart
test/get_asset_by_time_bucket_dto_test.dart test/get_asset_by_time_bucket_dto_test.dart
test/get_asset_count_by_time_bucket_dto_test.dart test/get_asset_count_by_time_bucket_dto_test.dart
@ -243,6 +258,9 @@ test/server_info_response_dto_test.dart
test/server_ping_response_test.dart test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart test/server_stats_response_dto_test.dart
test/server_version_reponse_dto_test.dart test/server_version_reponse_dto_test.dart
test/share_api_test.dart
test/shared_link_response_dto_test.dart
test/shared_link_type_test.dart
test/sign_up_dto_test.dart test/sign_up_dto_test.dart
test/smart_info_response_dto_test.dart test/smart_info_response_dto_test.dart
test/system_config_api_test.dart test/system_config_api_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/DownloadFilesDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/EditSharedLinkDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/ShareApi.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SharedLinkType.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/share_api.dart generated Normal file

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.

BIN
mobile/openapi/test/share_api_test.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,10 +1,6 @@
# User defined storage structure ## Public sharing
# Folder structure ### Albums
* Year is the top level
* Different parsing sequence will be the second level
# Filename - [ ] Add asset to shared link when new asset is added to shared album
* Filename will always be appended by a unique ID. Maybe use https://github.com/ai/nanoid - [ ] Prevent public user to delete asset from shared album
* Example: `notes.md` -> `notes-1234567890.md`
* Filename will be unique in the same folder

View file

@ -1,7 +1,7 @@
import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/database'; import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/database';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository, SelectQueryBuilder, DataSource, Brackets } from 'typeorm'; import { In, Repository, SelectQueryBuilder, DataSource, Brackets, Not, IsNull } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto'; import { AddUsersDto } from './dto/add-users.dto';
import { CreateAlbumDto } from './dto/create-album.dto'; import { CreateAlbumDto } from './dto/create-album.dto';
@ -14,6 +14,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository { export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>; create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>; getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
getPublicSharingList(ownerId: string): Promise<AlbumEntity[]>;
get(albumId: string): Promise<AlbumEntity | undefined>; get(albumId: string): Promise<AlbumEntity | undefined>;
delete(album: AlbumEntity): Promise<void>; delete(album: AlbumEntity): Promise<void>;
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>; addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
@ -43,6 +44,21 @@ export class AlbumRepository implements IAlbumRepository {
private dataSource: DataSource, private dataSource: DataSource,
) {} ) {}
async getPublicSharingList(ownerId: string): Promise<AlbumEntity[]> {
return this.albumRepository.find({
relations: {
sharedLinks: true,
assets: true,
},
where: {
ownerId,
sharedLinks: {
id: Not(IsNull()),
},
},
});
}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> { async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] }); const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
@ -161,6 +177,9 @@ export class AlbumRepository implements IAlbumRepository {
.leftJoinAndSelect('assets.assetInfo', 'assetInfo') .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC'); .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
// Get information of shared links in albums
query = query.leftJoinAndSelect('album.sharedLinks', 'sharedLink');
const albums = await query.getMany(); const albums = await query.getMany();
albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf()); albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());
@ -203,6 +222,7 @@ export class AlbumRepository implements IAlbumRepository {
.leftJoinAndSelect('album.assets', 'assets') .leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo') .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.leftJoinAndSelect('assetInfo.exifInfo', 'exifInfo') .leftJoinAndSelect('assetInfo.exifInfo', 'exifInfo')
.leftJoinAndSelect('album.sharedLinks', 'sharedLinks')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC') .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getOne(); .getOne();

View file

@ -33,25 +33,29 @@ import {
IMMICH_CONTENT_LENGTH_HINT, IMMICH_CONTENT_LENGTH_HINT,
} from '../../constants/download.constant'; } from '../../constants/download.constant';
import { DownloadDto } from '../asset/dto/download-library.dto'; import { DownloadDto } from '../asset/dto/download-library.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe. // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
@Authenticated()
@ApiBearerAuth() @ApiBearerAuth()
@ApiTags('Album') @ApiTags('Album')
@Controller('album') @Controller('album')
export class AlbumController { export class AlbumController {
constructor(private readonly albumService: AlbumService) {} constructor(private readonly albumService: AlbumService) {}
@Authenticated()
@Get('count-by-user-id') @Get('count-by-user-id')
async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> { async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.albumService.getAlbumCountByUserId(authUser); return this.albumService.getAlbumCountByUserId(authUser);
} }
@Authenticated()
@Post() @Post()
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) { async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
return this.albumService.create(authUser, createAlbumDto); return this.albumService.create(authUser, createAlbumDto);
} }
@Authenticated()
@Put('/:albumId/users') @Put('/:albumId/users')
async addUsersToAlbum( async addUsersToAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -61,6 +65,7 @@ export class AlbumController {
return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId); return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
} }
@Authenticated({ isShared: true })
@Put('/:albumId/assets') @Put('/:albumId/assets')
async addAssetsToAlbum( async addAssetsToAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -70,6 +75,7 @@ export class AlbumController {
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId); return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
} }
@Authenticated()
@Get() @Get()
async getAllAlbums( async getAllAlbums(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -78,6 +84,7 @@ export class AlbumController {
return this.albumService.getAllAlbums(authUser, query); return this.albumService.getAllAlbums(authUser, query);
} }
@Authenticated({ isShared: true })
@Get('/:albumId') @Get('/:albumId')
async getAlbumInfo( async getAlbumInfo(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -86,6 +93,7 @@ export class AlbumController {
return this.albumService.getAlbumInfo(authUser, albumId); return this.albumService.getAlbumInfo(authUser, albumId);
} }
@Authenticated()
@Delete('/:albumId/assets') @Delete('/:albumId/assets')
async removeAssetFromAlbum( async removeAssetFromAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -95,6 +103,7 @@ export class AlbumController {
return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId); return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
} }
@Authenticated()
@Delete('/:albumId') @Delete('/:albumId')
async deleteAlbum( async deleteAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -103,6 +112,7 @@ export class AlbumController {
return this.albumService.deleteAlbum(authUser, albumId); return this.albumService.deleteAlbum(authUser, albumId);
} }
@Authenticated()
@Delete('/:albumId/user/:userId') @Delete('/:albumId/user/:userId')
async removeUserFromAlbum( async removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -112,6 +122,7 @@ export class AlbumController {
return this.albumService.removeUserFromAlbum(authUser, albumId, userId); return this.albumService.removeUserFromAlbum(authUser, albumId, userId);
} }
@Authenticated()
@Patch('/:albumId') @Patch('/:albumId')
async updateAlbumInfo( async updateAlbumInfo(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -121,6 +132,7 @@ export class AlbumController {
return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId); return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId);
} }
@Authenticated({ isShared: true })
@Get('/:albumId/download') @Get('/:albumId/download')
async downloadArchive( async downloadArchive(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -139,4 +151,13 @@ export class AlbumController {
res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`); res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`);
return stream; return stream;
} }
@Authenticated()
@Post('/create-shared-link')
async createAlbumSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) createAlbumShareLinkDto: CreateAlbumSharedLinkDto,
) {
return this.albumService.createAlbumSharedLink(authUser, createAlbumShareLinkDto);
}
} }

View file

@ -7,6 +7,7 @@ import { AlbumRepository, IAlbumRepository } from './album-repository';
import { DownloadModule } from '../../modules/download/download.module'; import { DownloadModule } from '../../modules/download/download.module';
import { AssetModule } from '../asset/asset.module'; import { AssetModule } from '../asset/asset.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { ShareModule } from '../share/share.module';
const ALBUM_REPOSITORY_PROVIDER = { const ALBUM_REPOSITORY_PROVIDER = {
provide: IAlbumRepository, provide: IAlbumRepository,
@ -19,6 +20,7 @@ const ALBUM_REPOSITORY_PROVIDER = {
DownloadModule, DownloadModule,
UserModule, UserModule,
forwardRef(() => AssetModule), forwardRef(() => AssetModule),
ShareModule,
], ],
controllers: [AlbumController], controllers: [AlbumController],
providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER], providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],

View file

@ -3,15 +3,15 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database'; import { AlbumEntity } from '@app/database';
import { AlbumResponseDto } from './response-dto/album-response.dto'; import { AlbumResponseDto } from './response-dto/album-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository'; import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service'; import { DownloadService } from '../../modules/download/download.service';
import { ISharedLinkRepository } from '../share/shared-link.repository';
describe('Album service', () => { describe('Album service', () => {
let sut: AlbumService; let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>; let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>; let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>; let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
@ -33,7 +33,7 @@ describe('Album service', () => {
albumEntity.sharedUsers = []; albumEntity.sharedUsers = [];
albumEntity.assets = []; albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null; albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedLinks = [];
return albumEntity; return albumEntity;
}; };
@ -94,6 +94,7 @@ describe('Album service', () => {
}, },
}, },
]; ];
albumEntity.sharedLinks = [];
return albumEntity; return albumEntity;
}; };
@ -113,6 +114,7 @@ describe('Album service', () => {
beforeAll(() => { beforeAll(() => {
albumRepositoryMock = { albumRepositoryMock = {
getPublicSharingList: jest.fn(),
addAssets: jest.fn(), addAssets: jest.fn(),
addSharedUsers: jest.fn(), addSharedUsers: jest.fn(),
create: jest.fn(), create: jest.fn(),
@ -127,31 +129,20 @@ describe('Album service', () => {
getSharedWithUserAlbumCount: jest.fn(), getSharedWithUserAlbumCount: jest.fn(),
}; };
assetRepositoryMock = { sharedLinkRepositoryMock = {
create: jest.fn(), create: jest.fn(),
update: jest.fn(), remove: jest.fn(),
getAllByUserId: jest.fn(), get: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(), getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(), getByKey: jest.fn(),
getLocationsByUserId: jest.fn(), save: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
}; };
downloadServiceMock = { downloadServiceMock = {
downloadArchive: jest.fn(), downloadArchive: jest.fn(),
}; };
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock, downloadServiceMock as DownloadService); sut = new AlbumService(albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService);
}); });
it('creates album', async () => { it('creates album', async () => {
@ -175,10 +166,8 @@ describe('Album service', () => {
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums)); albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
const result = await sut.getAllAlbums(authUser, {}); const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(3); expect(result).toHaveLength(1);
expect(result[0].id).toEqual(ownedAlbum.id); expect(result[0].id).toEqual(ownedAlbum.id);
expect(result[1].id).toEqual(ownedSharedAlbum.id);
expect(result[2].id).toEqual(sharedWithMeAlbum.id);
}); });
it('gets an owned album', async () => { it('gets an owned album', async () => {

View file

@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAlbumDto } from './dto/create-album.dto'; import { CreateAlbumDto } from './dto/create-album.dto';
import { AlbumEntity } from '@app/database'; import { AlbumEntity, SharedLinkType } from '@app/database';
import { AddUsersDto } from './dto/add-users.dto'; import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto'; import { UpdateAlbumDto } from './dto/update-album.dto';
@ -9,19 +9,28 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { IAlbumRepository } from './album-repository'; import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { DownloadService } from '../../modules/download/download.service'; import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto'; import { DownloadDto } from '../asset/dto/download-library.dto';
import { ShareCore } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import _ from 'lodash';
@Injectable() @Injectable()
export class AlbumService { export class AlbumService {
readonly logger = new Logger(AlbumService.name);
private shareCore: ShareCore;
constructor( constructor(
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService, private downloadService: DownloadService,
) {} ) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
private async _getAlbum({ private async _getAlbum({
authUser, authUser,
@ -63,8 +72,14 @@ export class AlbumService {
albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId); albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId);
} else { } else {
albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
if (getAlbumsDto.shared) {
const publicSharingAlbums = await this._albumRepository.getPublicSharingList(authUser.id);
albums = [...albums, ...publicSharingAlbums];
}
} }
albums = _.uniqBy(albums, (album) => album.id);
for (const album of albums) { for (const album of albums) {
await this._checkValidThumbnail(album); await this._checkValidThumbnail(album);
} }
@ -85,6 +100,11 @@ export class AlbumService {
async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> { async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> {
const album = await this._getAlbum({ authUser, albumId }); const album = await this._getAlbum({ authUser, albumId });
for (const sharedLink of album.sharedLinks) {
await this.shareCore.removeSharedLink(sharedLink.id, authUser.id);
}
await this._albumRepository.delete(album); await this._albumRepository.delete(album);
} }
@ -125,6 +145,11 @@ export class AlbumService {
addAssetsDto: AddAssetsDto, addAssetsDto: AddAssetsDto,
albumId: string, albumId: string,
): Promise<AddAssetsResponseDto> { ): Promise<AddAssetsResponseDto> {
if (authUser.isPublicUser && !authUser.isAllowUpload) {
this.logger.warn('Deny public user attempt to add asset to album');
throw new ForbiddenException('Public user is not allowed to upload');
}
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const result = await this._albumRepository.addAssets(album, addAssetsDto); const result = await this._albumRepository.addAssets(album, addAssetsDto);
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
@ -174,4 +199,19 @@ export class AlbumService {
album.albumThumbnailAssetId = dto.albumThumbnailAssetId || null; album.albumThumbnailAssetId = dto.albumThumbnailAssetId || null;
} }
} }
async createAlbumSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
const album = await this._getAlbum({ authUser, albumId: dto.albumId });
const sharedLink = await this.shareCore.createSharedLink(authUser.id, {
sharedType: SharedLinkType.ALBUM,
expiredAt: dto.expiredAt,
allowUpload: dto.allowUpload,
album: album,
assets: [],
description: dto.description,
});
return mapSharedLinkToResponseDto(sharedLink);
}
} }

View file

@ -0,0 +1,19 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAlbumShareLinkDto {
@IsString()
@IsNotEmpty()
albumId!: string;
@IsString()
@IsOptional()
expiredAt?: string;
@IsBoolean()
@IsOptional()
allowUpload?: boolean;
@IsString()
@IsOptional()
description?: string;
}

View file

@ -33,7 +33,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
id: entity.id, id: entity.id,
ownerId: entity.ownerId, ownerId: entity.ownerId,
sharedUsers, sharedUsers,
shared: sharedUsers.length > 0, shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [], assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
}; };
@ -55,7 +55,7 @@ export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto
id: entity.id, id: entity.id,
ownerId: entity.ownerId, ownerId: entity.ownerId,
sharedUsers, sharedUsers,
shared: sharedUsers.length > 0, shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: [], assets: [],
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
}; };

View file

@ -226,7 +226,7 @@ export class AssetRepository implements IAssetRepository {
where: { where: {
id: assetId, id: assetId,
}, },
relations: ['exifInfo', 'tags'], relations: ['exifInfo', 'tags', 'sharedLinks'],
}); });
} }

View file

@ -49,14 +49,15 @@ import {
IMMICH_ARCHIVE_FILE_COUNT, IMMICH_ARCHIVE_FILE_COUNT,
IMMICH_CONTENT_LENGTH_HINT, IMMICH_CONTENT_LENGTH_HINT,
} from '../../constants/download.constant'; } from '../../constants/download.constant';
import { DownloadFilesDto } from './dto/download-files.dto';
@Authenticated()
@ApiBearerAuth() @ApiBearerAuth()
@ApiTags('Asset') @ApiTags('Asset')
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {} constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
@Authenticated({ isShared: true })
@Post('upload') @Post('upload')
@UseInterceptors( @UseInterceptors(
FileFieldsInterceptor( FileFieldsInterceptor(
@ -84,6 +85,7 @@ export class AssetController {
return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData); return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
} }
@Authenticated({ isShared: true })
@Get('/download/:assetId') @Get('/download/:assetId')
async downloadFile( async downloadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -95,6 +97,23 @@ export class AssetController {
return this.assetService.downloadFile(query, assetId, res); return this.assetService.downloadFile(query, assetId, res);
} }
@Authenticated({ isShared: true })
@Post('/download-files')
async downloadFiles(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto,
): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]);
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto);
res.attachment(fileName);
res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize);
res.setHeader(IMMICH_ARCHIVE_FILE_COUNT, fileCount);
res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`);
return stream;
}
@Authenticated({ isShared: true })
@Get('/download-library') @Get('/download-library')
async downloadLibrary( async downloadLibrary(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -109,6 +128,7 @@ export class AssetController {
return stream; return stream;
} }
@Authenticated({ isShared: true })
@Get('/file/:assetId') @Get('/file/:assetId')
@Header('Cache-Control', 'max-age=31536000') @Header('Cache-Control', 'max-age=31536000')
async serveFile( async serveFile(
@ -122,6 +142,7 @@ export class AssetController {
return this.assetService.serveFile(assetId, query, res, headers); return this.assetService.serveFile(assetId, query, res, headers);
} }
@Authenticated({ isShared: true })
@Get('/thumbnail/:assetId') @Get('/thumbnail/:assetId')
@Header('Cache-Control', 'max-age=31536000') @Header('Cache-Control', 'max-age=31536000')
async getAssetThumbnail( async getAssetThumbnail(
@ -135,21 +156,25 @@ export class AssetController {
return this.assetService.getAssetThumbnail(assetId, query, res, headers); return this.assetService.getAssetThumbnail(assetId, query, res, headers);
} }
@Authenticated()
@Get('/curated-objects') @Get('/curated-objects')
async getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> { async getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
return this.assetService.getCuratedObject(authUser); return this.assetService.getCuratedObject(authUser);
} }
@Authenticated()
@Get('/curated-locations') @Get('/curated-locations')
async getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> { async getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
return this.assetService.getCuratedLocation(authUser); return this.assetService.getCuratedLocation(authUser);
} }
@Authenticated()
@Get('/search-terms') @Get('/search-terms')
async getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise<string[]> { async getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise<string[]> {
return this.assetService.getAssetSearchTerm(authUser); return this.assetService.getAssetSearchTerm(authUser);
} }
@Authenticated()
@Post('/search') @Post('/search')
async searchAsset( async searchAsset(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -158,6 +183,7 @@ export class AssetController {
return this.assetService.searchAsset(authUser, searchAssetDto); return this.assetService.searchAsset(authUser, searchAssetDto);
} }
@Authenticated()
@Post('/count-by-time-bucket') @Post('/count-by-time-bucket')
async getAssetCountByTimeBucket( async getAssetCountByTimeBucket(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -166,6 +192,7 @@ export class AssetController {
return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto); return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
} }
@Authenticated()
@Get('/count-by-user-id') @Get('/count-by-user-id')
async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> { async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser); return this.assetService.getAssetCountByUserId(authUser);
@ -174,6 +201,7 @@ export class AssetController {
/** /**
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
*/ */
@Authenticated()
@Get('/') @Get('/')
@ApiHeader({ @ApiHeader({
name: 'if-none-match', name: 'if-none-match',
@ -186,6 +214,7 @@ export class AssetController {
return assets; return assets;
} }
@Authenticated()
@Post('/time-bucket') @Post('/time-bucket')
async getAssetByTimeBucket( async getAssetByTimeBucket(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -193,9 +222,11 @@ export class AssetController {
): Promise<AssetResponseDto[]> { ): Promise<AssetResponseDto[]> {
return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto); return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
} }
/** /**
* Get all asset of a device that are in the database, ID only. * Get all asset of a device that are in the database, ID only.
*/ */
@Authenticated()
@Get('/:deviceId') @Get('/:deviceId')
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) { async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId); return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
@ -204,6 +235,7 @@ export class AssetController {
/** /**
* Get a single asset's information * Get a single asset's information
*/ */
@Authenticated({ isShared: true })
@Get('/assetById/:assetId') @Get('/assetById/:assetId')
async getAssetById( async getAssetById(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -216,6 +248,7 @@ export class AssetController {
/** /**
* Update an asset * Update an asset
*/ */
@Authenticated()
@Put('/:assetId') @Put('/:assetId')
async updateAsset( async updateAsset(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -226,6 +259,7 @@ export class AssetController {
return await this.assetService.updateAsset(authUser, assetId, dto); return await this.assetService.updateAsset(authUser, assetId, dto);
} }
@Authenticated()
@Delete('/') @Delete('/')
async deleteAsset( async deleteAsset(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@ -265,6 +299,7 @@ export class AssetController {
/** /**
* Check duplicated asset before uploading - for Web upload used * Check duplicated asset before uploading - for Web upload used
*/ */
@Authenticated({ isShared: true })
@Post('/check') @Post('/check')
@HttpCode(200) @HttpCode(200)
async checkDuplicateAsset( async checkDuplicateAsset(
@ -277,6 +312,7 @@ export class AssetController {
/** /**
* Checks if multiple assets exist on the server and returns all existing - used by background backup * Checks if multiple assets exist on the server and returns all existing - used by background backup
*/ */
@Authenticated()
@Post('/exist') @Post('/exist')
@HttpCode(200) @HttpCode(200)
async checkExistingAssets( async checkExistingAssets(

View file

@ -14,6 +14,7 @@ import { AlbumModule } from '../album/album.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { StorageModule } from '@app/storage'; import { StorageModule } from '@app/storage';
import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant';
import { ShareModule } from '../share/share.module';
const ASSET_REPOSITORY_PROVIDER = { const ASSET_REPOSITORY_PROVIDER = {
provide: IAssetRepository, provide: IAssetRepository,
@ -32,6 +33,7 @@ const ASSET_REPOSITORY_PROVIDER = {
StorageModule, StorageModule,
forwardRef(() => AlbumModule), forwardRef(() => AlbumModule),
BullModule.registerQueue(...immichSharedQueues), BullModule.registerQueue(...immichSharedQueues),
ShareModule,
], ],
controllers: [AssetController], controllers: [AssetController],
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER], providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],

View file

@ -13,6 +13,7 @@ import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { IAlbumRepository } from '../album/album-repository'; import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage'; import { StorageService } from '@app/storage';
import { ISharedLinkRepository } from '../share/shared-link.repository';
describe('AssetService', () => { describe('AssetService', () => {
let sui: AssetService; let sui: AssetService;
@ -24,6 +25,7 @@ describe('AssetService', () => {
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>; let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>; let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
let storageSeriveMock: jest.Mocked<StorageService>; let storageSeriveMock: jest.Mocked<StorageService>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1', id: 'user_id_1',
email: 'auth@test.com', email: 'auth@test.com',
@ -128,12 +130,22 @@ describe('AssetService', () => {
getAssetWithNoSmartInfo: jest.fn(), getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(), getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(), countByIdAndUser: jest.fn(),
getSharePermission: jest.fn(),
}; };
downloadServiceMock = { downloadServiceMock = {
downloadArchive: jest.fn(), downloadArchive: jest.fn(),
}; };
sharedLinkRepositoryMock = {
create: jest.fn(),
get: jest.fn(),
getById: jest.fn(),
getByKey: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
};
sui = new AssetService( sui = new AssetService(
assetRepositoryMock, assetRepositoryMock,
albumRepositoryMock, albumRepositoryMock,
@ -143,6 +155,7 @@ describe('AssetService', () => {
videoConversionQueueMock, videoConversionQueueMock,
downloadServiceMock as DownloadService, downloadServiceMock as DownloadService,
storageSeriveMock, storageSeriveMock,
sharedLinkRepositoryMock,
); );
}); });

View file

@ -56,11 +56,17 @@ import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto'; import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository'; import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage'; import { StorageService } from '@app/storage';
import { ShareCore } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { DownloadFilesDto } from './dto/download-files.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@Injectable() @Injectable()
export class AssetService { export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: ShareCore;
constructor( constructor(
@Inject(IAssetRepository) private _assetRepository: IAssetRepository, @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@ -80,7 +86,10 @@ export class AssetService {
private downloadService: DownloadService, private downloadService: DownloadService,
private storageService: StorageService, private storageService: StorageService,
) {} @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
public async handleUploadedAsset( public async handleUploadedAsset(
authUser: AuthUserDto, authUser: AuthUserDto,
@ -253,6 +262,24 @@ export class AssetService {
return this.downloadService.downloadArchive(dto.name || `library`, assets); return this.downloadService.downloadArchive(dto.name || `library`, assets);
} }
public async downloadFiles(dto: DownloadFilesDto) {
const assetToDownload = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assetToDownload.push(asset);
// Get live photo asset
if (asset.livePhotoVideoId) {
const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId);
assetToDownload.push(livePhotoAsset);
}
}
const now = new Date().toISOString();
return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload);
}
public async downloadFile(query: ServeFileDto, assetId: string, res: Res) { public async downloadFile(query: ServeFileDto, assetId: string, res: Res) {
try { try {
let fileReadStream = null; let fileReadStream = null;
@ -649,7 +676,15 @@ export class AssetService {
async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) { async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
for (const assetId of assetIds) { for (const assetId of assetIds) {
// Step 1: Check if user owns asset // Step 1: Check if asset is part of a public shared
if (authUser.sharedLinkId) {
const canAccess = await this.shareCore.hasAssetAccess(authUser.sharedLinkId, assetId);
if (!canAccess) {
throw new ForbiddenException();
}
}
// Step 2: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) { if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue; continue;
} }
@ -660,8 +695,6 @@ export class AssetService {
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) { if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
continue; continue;
} }
//TODO: Step 3: Check if asset is part of a public album
} }
throw new ForbiddenException(); throw new ForbiddenException();
} }

View file

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class DownloadFilesDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of asset ids to be downloaded',
})
assetIds!: string[];
}

View file

@ -0,0 +1,11 @@
import { AlbumEntity, AssetEntity } from '@app/database';
import { SharedLinkType } from '@app/database/entities/shared-link.entity';
export class CreateSharedLinkDto {
description?: string;
expiredAt?: string;
sharedType!: SharedLinkType;
assets!: AssetEntity[];
album?: AlbumEntity;
allowUpload?: boolean;
}

View file

@ -0,0 +1,15 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class EditSharedLinkDto {
@IsOptional()
description?: string;
@IsOptional()
expiredAt?: string;
@IsOptional()
allowUpload?: boolean;
@IsNotEmpty()
isEditExpireTime?: boolean;
}

View file

@ -0,0 +1,40 @@
import { SharedLinkEntity, SharedLinkType } from '@app/database';
import { ApiProperty } from '@nestjs/swagger';
import _ from 'lodash';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto';
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
export class SharedLinkResponseDto {
id!: string;
description?: string;
userId!: string;
key!: string;
@ApiProperty({ enumName: 'SharedLinkType', enum: SharedLinkType })
type!: SharedLinkType;
createdAt!: string;
expiresAt!: string | null;
assets!: AssetResponseDto[];
album?: AlbumResponseDto;
allowUpload!: boolean;
}
export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
description: sharedLink.description,
userId: sharedLink.userId,
key: sharedLink.key.toString('hex'),
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map(mapAsset),
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
};
}

View file

@ -0,0 +1,46 @@
import { Body, Controller, Delete, Get, Param, Patch, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
import { ShareService } from './share.service';
@ApiTags('share')
@Controller('share')
export class ShareController {
constructor(private readonly shareService: ShareService) {}
@Authenticated()
@Get()
getAllSharedLinks(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
return this.shareService.getAll(authUser);
}
@Authenticated({ isShared: true })
@Get('me')
getMySharedLink(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
return this.shareService.getMine(authUser);
}
@Authenticated()
@Get(':id')
getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
return this.shareService.getById(id);
}
@Authenticated()
@Delete(':id')
removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise<string> {
return this.shareService.remove(id, authUser.id);
}
@Authenticated()
@Patch(':id')
editSharedLink(
@Param('id') id: string,
@GetAuthUser() authUser: AuthUserDto,
@Body(new ValidationPipe()) dto: EditSharedLinkDto,
): Promise<SharedLinkResponseDto> {
return this.shareService.edit(id, authUser, dto);
}
}

View file

@ -0,0 +1,99 @@
import { SharedLinkEntity } from '@app/database/entities/shared-link.entity';
import { CreateSharedLinkDto } from './dto/create-shared-link.dto';
import { ISharedLinkRepository } from './shared-link.repository';
import crypto from 'node:crypto';
import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
import { AssetEntity } from '@app/database';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
export class ShareCore {
readonly logger = new Logger(ShareCore.name);
constructor(private sharedLinkRepository: ISharedLinkRepository) {}
async createSharedLink(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
try {
const sharedLink = new SharedLinkEntity();
sharedLink.key = Buffer.from(crypto.randomBytes(50));
sharedLink.description = dto.description;
sharedLink.userId = userId;
sharedLink.createdAt = new Date().toISOString();
sharedLink.expiresAt = dto.expiredAt ?? null;
sharedLink.type = dto.sharedType;
sharedLink.assets = dto.assets;
sharedLink.album = dto.album;
sharedLink.allowUpload = dto.allowUpload ?? false;
return this.sharedLinkRepository.create(sharedLink);
} catch (error: any) {
this.logger.error(error, error.stack);
throw new InternalServerErrorException('failed to create shared link');
}
}
async getSharedLinks(userId: string): Promise<SharedLinkEntity[]> {
return this.sharedLinkRepository.get(userId);
}
async removeSharedLink(id: string, userId: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return await this.sharedLinkRepository.remove(link);
}
async getSharedLinkById(id: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getById(id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return link;
}
async getSharedLinkByKey(key: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByKey(key);
if (!link) {
throw new BadRequestException();
}
return link;
}
async updateAssetsInSharedLink(sharedLinkId: string, assets: AssetEntity[]) {
const link = await this.getSharedLinkById(sharedLinkId);
link.assets = assets;
return await this.sharedLinkRepository.save(link);
}
async updateSharedLink(id: string, userId: string, dto: EditSharedLinkDto): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
link.description = dto.description ?? link.description;
link.allowUpload = dto.allowUpload ?? link.allowUpload;
if (dto.isEditExpireTime && dto.expiredAt) {
link.expiresAt = dto.expiredAt;
} else if (dto.isEditExpireTime && !dto.expiredAt) {
link.expiresAt = null;
}
return await this.sharedLinkRepository.save(link);
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
return this.sharedLinkRepository.hasAssetAccess(id, assetId);
}
}

View file

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ShareService } from './share.service';
import { ShareController } from './share.controller';
import { SharedLinkEntity } from '@app/database/entities/shared-link.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SharedLinkRepository, ISharedLinkRepository } from './shared-link.repository';
const SHARED_LINK_REPOSITORY_PROVIDER = {
provide: ISharedLinkRepository,
useClass: SharedLinkRepository,
};
@Module({
imports: [TypeOrmModule.forFeature([SharedLinkEntity])],
controllers: [ShareController],
providers: [ShareService, SHARED_LINK_REPOSITORY_PROVIDER],
exports: [SHARED_LINK_REPOSITORY_PROVIDER, ShareService],
})
export class ShareModule {}

View file

@ -0,0 +1,54 @@
import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
import { ShareCore } from './share.core';
import { ISharedLinkRepository } from './shared-link.repository';
@Injectable()
export class ShareService {
readonly logger = new Logger(ShareService.name);
private shareCore: ShareCore;
constructor(
@Inject(ISharedLinkRepository)
sharedLinkRepository: ISharedLinkRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
const links = await this.shareCore.getSharedLinks(authUser.id);
return links.map(mapSharedLinkToResponseDto);
}
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
if (!authUser.isPublicUser || !authUser.sharedLinkId) {
throw new ForbiddenException();
}
const link = await this.shareCore.getSharedLinkById(authUser.sharedLinkId);
return mapSharedLinkToResponseDto(link);
}
async getById(id: string): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkById(id);
return mapSharedLinkToResponseDto(link);
}
async remove(id: string, userId: string): Promise<string> {
await this.shareCore.removeSharedLink(id, userId);
return id;
}
async getByKey(key: string): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkByKey(key);
return mapSharedLinkToResponseDto(link);
}
async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
return mapSharedLinkToResponseDto(link);
}
}

View file

@ -0,0 +1,123 @@
import { SharedLinkEntity } from '@app/database/entities/shared-link.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Logger } from '@nestjs/common';
export interface ISharedLinkRepository {
get(userId: string): Promise<SharedLinkEntity[]>;
getById(id: string): Promise<SharedLinkEntity | null>;
getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null>;
getByKey(key: string): Promise<SharedLinkEntity | null>;
create(payload: SharedLinkEntity): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
save(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}
export const ISharedLinkRepository = 'ISharedLinkRepository';
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly sharedLinkRepository: Repository<SharedLinkEntity>,
) {}
async getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
userId: userId,
id: id,
},
order: {
createdAt: 'DESC',
},
});
}
async get(userId: string): Promise<SharedLinkEntity[]> {
return await this.sharedLinkRepository.find({
where: {
userId: userId,
},
relations: ['assets', 'album'],
order: {
createdAt: 'DESC',
},
});
}
async create(payload: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.save(payload);
}
async getById(id: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
id: id,
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
async getByKey(key: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
key: Buffer.from(key, 'hex'),
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
async remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.save(entity);
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.sharedLinkRepository.count({
where: {
id,
assets: {
id: assetId,
},
},
});
const count2 = await this.sharedLinkRepository.count({
where: {
id,
album: {
assets: {
assetId,
},
},
},
});
return Boolean(count1 + count2);
}
}

View file

@ -19,6 +19,7 @@ import { JobModule } from './api-v1/job/job.module';
import { SystemConfigModule } from './api-v1/system-config/system-config.module'; import { SystemConfigModule } from './api-v1/system-config/system-config.module';
import { OAuthModule } from './api-v1/oauth/oauth.module'; import { OAuthModule } from './api-v1/oauth/oauth.module';
import { TagModule } from './api-v1/tag/tag.module'; import { TagModule } from './api-v1/tag/tag.module';
import { ShareModule } from './api-v1/share/share.module';
import { APIKeyModule } from './api-v1/api-key/api-key.module'; import { APIKeyModule } from './api-v1/api-key/api-key.module';
@Module({ @Module({
@ -58,6 +59,8 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module';
SystemConfigModule, SystemConfigModule,
TagModule, TagModule,
ShareModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [], providers: [],

View file

@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname, join } from 'path'; import { extname, join } from 'path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../decorators/auth-user.decorator';
import { patchFormData } from '../utils/path-form-data.util'; import { patchFormData } from '../utils/path-form-data.util';
const logger = new Logger('AssetUploadConfig'); const logger = new Logger('AssetUploadConfig');
@ -42,6 +43,12 @@ function destination(req: Request, file: Express.Multer.File, cb: any) {
return cb(new UnauthorizedException()); return cb(new UnauthorizedException());
} }
const user = req.user as AuthUserDto;
if (user.isPublicUser && !user.isAllowUpload) {
return cb(new UnauthorizedException());
}
const basePath = APP_UPLOAD_LOCATION; const basePath = APP_UPLOAD_LOCATION;
const sanitizedDeviceId = sanitize(String(req.body['deviceId'])); const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId); const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId);

View file

@ -1,23 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { UserEntity } from '@app/database';
// import { AuthUserDto } from './dto/auth-user.dto'; // import { AuthUserDto } from './dto/auth-user.dto';
export class AuthUserDto { export class AuthUserDto {
id!: string; id!: string;
email!: string; email!: string;
isAdmin!: boolean; isAdmin!: boolean;
isPublicUser?: boolean;
sharedLinkId?: string;
isAllowUpload?: boolean;
} }
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => { export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
const req = ctx.switchToHttp().getRequest<{ user: UserEntity }>(); return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
const { id, email, isAdmin } = req.user;
const authUser: AuthUserDto = {
id: id.toString(),
email,
isAdmin,
};
return authUser;
}); });

View file

@ -1,16 +1,25 @@
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware'; import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard'; import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard';
interface AuthenticatedOptions { interface AuthenticatedOptions {
admin?: boolean; admin?: boolean;
isShared?: boolean;
} }
export const Authenticated = (options?: AuthenticatedOptions) => { export const Authenticated = (options?: AuthenticatedOptions) => {
const guards: Parameters<typeof UseGuards> = [AuthGuard]; const guards: Parameters<typeof UseGuards> = [AuthGuard];
options = options || {}; options = options || {};
if (options.admin) { if (options.admin) {
guards.push(AdminRolesGuard); guards.push(AdminRolesGuard);
} }
if (!options.isShared) {
guards.push(RouteNotSharedGuard);
}
return UseGuards(...guards); return UseGuards(...guards);
}; };

View file

@ -0,0 +1,21 @@
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Request } from 'express';
import { AuthUserDto } from '../decorators/auth-user.decorator';
@Injectable()
export class RouteNotSharedGuard implements CanActivate {
logger = new Logger(RouteNotSharedGuard.name);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as AuthUserDto;
// Inverse logic - I know it is weird
if (user.isPublicUser) {
this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`);
return false;
}
return true;
}
}

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
import { JWT_STRATEGY } from '../strategies/jwt.strategy'; import { JWT_STRATEGY } from '../strategies/jwt.strategy';
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
@Injectable() @Injectable()
export class AuthGuard extends PassportAuthGuard([JWT_STRATEGY, API_KEY_STRATEGY]) {} export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {}

View file

@ -7,10 +7,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@app/database'; import { UserEntity } from '@app/database';
import { APIKeyModule } from '../../api-v1/api-key/api-key.module'; import { APIKeyModule } from '../../api-v1/api-key/api-key.module';
import { APIKeyStrategy } from './strategies/api-key.strategy'; import { APIKeyStrategy } from './strategies/api-key.strategy';
import { ShareModule } from '../../api-v1/share/share.module';
import { PublicShareStrategy } from './strategies/public-share.strategy';
@Module({ @Module({
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule], imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule, ShareModule],
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy], providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy],
exports: [ImmichJwtService], exports: [ImmichJwtService],
}) })
export class ImmichJwtModule {} export class ImmichJwtModule {}

View file

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { APIKeyService } from '../../../api-v1/api-key/api-key.service'; import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
@ -15,7 +16,16 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY)
super(options); super(options);
} }
async validate(token: string) { async validate(token: string): Promise<AuthUserDto> {
return this.apiKeyService.validate(token); const user = await this.apiKeyService.validate(token);
const authUser = new AuthUserDto();
authUser.id = user.id;
authUser.email = user.email;
authUser.isAdmin = user.isAdmin;
authUser.isPublicUser = false;
authUser.isAllowUpload = true;
return authUser;
} }
} }

View file

@ -7,6 +7,7 @@ import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { UserEntity } from '@app/database'; import { UserEntity } from '@app/database';
import { jwtSecret } from '../../../constants/jwt.constant'; import { jwtSecret } from '../../../constants/jwt.constant';
import { ImmichJwtService } from '../immich-jwt.service'; import { ImmichJwtService } from '../immich-jwt.service';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
export const JWT_STRATEGY = 'jwt'; export const JWT_STRATEGY = 'jwt';
@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
} as StrategyOptions); } as StrategyOptions);
} }
async validate(payload: JwtPayloadDto) { async validate(payload: JwtPayloadDto): Promise<AuthUserDto> {
const { userId } = payload; const { userId } = payload;
const user = await this.usersRepository.findOne({ where: { id: userId } }); const user = await this.usersRepository.findOne({ where: { id: userId } });
@ -35,6 +36,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
throw new UnauthorizedException('Failure to validate JWT payload'); throw new UnauthorizedException('Failure to validate JWT payload');
} }
return user; const authUser = new AuthUserDto();
authUser.id = user.id;
authUser.email = user.email;
authUser.isAdmin = user.isAdmin;
authUser.isPublicUser = false;
authUser.isAllowUpload = true;
return authUser;
} }
} }

View file

@ -0,0 +1,53 @@
import { UserEntity } from '@app/database';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ShareService } from '../../../api-v1/share/share.service';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { Repository } from 'typeorm';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
export const PUBLIC_SHARE_STRATEGY = 'public-share';
const options: IStrategyOptions = {
header: 'x-immich-share-key',
param: 'key',
};
@Injectable()
export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) {
constructor(
private shareService: ShareService,
@InjectRepository(UserEntity)
private usersRepository: Repository<UserEntity>,
) {
super(options);
}
async validate(key: string): Promise<AuthUserDto> {
const validatedLink = await this.shareService.getByKey(key);
if (validatedLink.expiresAt) {
const now = new Date().getTime();
const expiresAt = new Date(validatedLink.expiresAt).getTime();
if (now > expiresAt) {
throw new UnauthorizedException('Expired link');
}
}
const user = await this.usersRepository.findOne({ where: { id: validatedLink.userId } });
if (!user) {
throw new UnauthorizedException('Failure to validate public share payload');
}
let publicUser = new AuthUserDto();
publicUser = user;
publicUser.isPublicUser = true;
publicUser.sharedLinkId = validatedLink.id;
publicUser.isAllowUpload = validatedLink.allowUpload;
return publicUser;
}
}

View file

@ -473,6 +473,147 @@
] ]
} }
}, },
"/share": {
"get": {
"operationId": "getAllSharedLinks",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
}
},
"tags": [
"share"
]
}
},
"/share/me": {
"get": {
"operationId": "getMySharedLink",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"share"
]
}
},
"/share/{id}": {
"get": {
"operationId": "getSharedLinkById",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"share"
]
},
"delete": {
"operationId": "removeSharedLink",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
},
"tags": [
"share"
]
},
"patch": {
"operationId": "editSharedLink",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditSharedLinkDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"share"
]
}
},
"/asset/upload": { "/asset/upload": {
"post": { "post": {
"operationId": "uploadFile", "operationId": "uploadFile",
@ -563,6 +704,42 @@
] ]
} }
}, },
"/asset/download-files": {
"post": {
"operationId": "downloadFiles",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadFilesDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
}
]
}
},
"/asset/download-library": { "/asset/download-library": {
"get": { "get": {
"operationId": "downloadLibrary", "operationId": "downloadLibrary",
@ -1616,6 +1793,42 @@
] ]
} }
}, },
"/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": []
}
]
}
},
"/tag": { "/tag": {
"post": { "post": {
"operationId": "create", "operationId": "create",
@ -2666,99 +2879,11 @@
"name" "name"
] ]
}, },
"AssetFileUploadDto": { "SharedLinkType": {
"type": "object",
"properties": {
"assetData": {
"type": "string",
"format": "binary"
}
},
"required": [
"assetData"
]
},
"AssetFileUploadResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
},
"ThumbnailFormat": {
"type": "string", "type": "string",
"enum": [ "enum": [
"JPEG", "ALBUM",
"WEBP" "INDIVIDUAL"
]
},
"CuratedObjectsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"object": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"object",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"CuratedLocationsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"city": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"city",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"SearchAssetDto": {
"type": "object",
"properties": {
"searchTerm": {
"type": "string"
}
},
"required": [
"searchTerm"
] ]
}, },
"AssetTypeEnum": { "AssetTypeEnum": {
@ -3019,6 +3144,232 @@
"tags" "tags"
] ]
}, },
"AlbumResponseDto": {
"type": "object",
"properties": {
"assetCount": {
"type": "integer"
},
"id": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"albumName": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"albumThumbnailAssetId": {
"type": "string",
"nullable": true
},
"shared": {
"type": "boolean"
},
"sharedUsers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserResponseDto"
}
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
}
},
"required": [
"assetCount",
"id",
"ownerId",
"albumName",
"createdAt",
"albumThumbnailAssetId",
"shared",
"sharedUsers",
"assets"
]
},
"SharedLinkResponseDto": {
"type": "object",
"properties": {
"type": {
"$ref": "#/components/schemas/SharedLinkType"
},
"id": {
"type": "string"
},
"description": {
"type": "string"
},
"userId": {
"type": "string"
},
"key": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"expiresAt": {
"type": "string",
"nullable": true
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
},
"album": {
"$ref": "#/components/schemas/AlbumResponseDto"
},
"allowUpload": {
"type": "boolean"
}
},
"required": [
"type",
"id",
"userId",
"key",
"createdAt",
"expiresAt",
"assets",
"allowUpload"
]
},
"EditSharedLinkDto": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"expiredAt": {
"type": "string"
},
"allowUpload": {
"type": "boolean"
},
"isEditExpireTime": {
"type": "boolean"
}
}
},
"AssetFileUploadDto": {
"type": "object",
"properties": {
"assetData": {
"type": "string",
"format": "binary"
}
},
"required": [
"assetData"
]
},
"AssetFileUploadResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
},
"DownloadFilesDto": {
"type": "object",
"properties": {
"assetIds": {
"title": "Array of asset ids to be downloaded",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"assetIds"
]
},
"ThumbnailFormat": {
"type": "string",
"enum": [
"JPEG",
"WEBP"
]
},
"CuratedObjectsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"object": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"object",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"CuratedLocationsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"city": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"city",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"SearchAssetDto": {
"type": "object",
"properties": {
"searchTerm": {
"type": "string"
}
},
"required": [
"searchTerm"
]
},
"TimeGroupEnum": { "TimeGroupEnum": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -3287,56 +3638,6 @@
"albumName" "albumName"
] ]
}, },
"AlbumResponseDto": {
"type": "object",
"properties": {
"assetCount": {
"type": "integer"
},
"id": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"albumName": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"albumThumbnailAssetId": {
"type": "string",
"nullable": true
},
"shared": {
"type": "boolean"
},
"sharedUsers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserResponseDto"
}
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
}
},
"required": [
"assetCount",
"id",
"ownerId",
"albumName",
"createdAt",
"albumThumbnailAssetId",
"shared",
"sharedUsers",
"assets"
]
},
"AddUsersDto": { "AddUsersDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -3411,6 +3712,26 @@
} }
} }
}, },
"CreateAlbumShareLinkDto": {
"type": "object",
"properties": {
"albumId": {
"type": "string"
},
"expiredAt": {
"type": "string"
},
"allowUpload": {
"type": "boolean"
},
"description": {
"type": "string"
}
},
"required": [
"albumId"
]
},
"CreateTagDto": { "CreateTagDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -1,5 +1,6 @@
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { AssetAlbumEntity } from './asset-album.entity'; import { AssetAlbumEntity } from './asset-album.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { UserAlbumEntity } from './user-album.entity'; import { UserAlbumEntity } from './user-album.entity';
@Entity('albums') @Entity('albums')
@ -24,4 +25,7 @@ export class AlbumEntity {
@OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo) @OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo)
assets?: AssetAlbumEntity[]; assets?: AssetAlbumEntity[];
@OneToMany(() => SharedLinkEntity, (link) => link.album)
sharedLinks!: SharedLinkEntity[];
} }

View file

@ -1,5 +1,6 @@
import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity'; import { ExifEntity } from './exif.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { SmartInfoEntity } from './smart-info.entity'; import { SmartInfoEntity } from './smart-info.entity';
import { TagEntity } from './tag.entity'; import { TagEntity } from './tag.entity';
@ -68,6 +69,10 @@ export class AssetEntity {
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true }) @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
@JoinTable({ name: 'tag_asset' }) @JoinTable({ name: 'tag_asset' })
tags!: TagEntity[]; tags!: TagEntity[];
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
@JoinTable({ name: 'shared_link__asset' })
sharedLinks!: SharedLinkEntity[];
} }
export enum AssetType { export enum AssetType {

View file

@ -9,3 +9,4 @@ export * from './system-config.entity';
export * from './tag.entity'; export * from './tag.entity';
export * from './user-album.entity'; export * from './user-album.entity';
export * from './user.entity'; export * from './user.entity';
export * from './shared-link.entity';

View file

@ -0,0 +1,50 @@
import { Column, Entity, Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { AlbumEntity } from './album.entity';
import { AssetEntity } from './asset.entity';
@Entity('shared_links')
@Unique('UQ_sharedlink_key', ['key'])
export class SharedLinkEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ nullable: true })
description?: string;
@Column()
userId!: string;
@Index('IDX_sharedlink_key')
@Column({ type: 'bytea' })
key!: Buffer; // use to access the inidividual asset
@Column()
type!: SharedLinkType;
@Column({ type: 'timestamptz' })
createdAt!: string;
@Column({ type: 'timestamptz', nullable: true })
expiresAt!: string | null;
@Column({ type: 'boolean', default: false })
allowUpload!: boolean;
@ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks)
assets!: AssetEntity[];
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks)
album?: AlbumEntity;
}
export enum SharedLinkType {
ALBUM = 'ALBUM',
/**
* Individual asset
* or group of assets that are not in an album
*/
INDIVIDUAL = 'INDIVIDUAL',
}
// npm run typeorm -- migration:generate ./libs/database/src/AddSharedLinkTable -d libs/database/src/config/database.config.ts

View file

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSharedLinkTable1673150490490 implements MigrationInterface {
name = 'AddSharedLinkTable1673150490490'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" character varying NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, CONSTRAINT "UQ_sharedlink_key" UNIQUE ("key"), CONSTRAINT "PK_642e2b0f619e4876e5f90a43465" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_sharedlink_key" ON "shared_links" ("key") `);
await queryRunner.query(`CREATE TABLE "shared_link__asset" ("assetsId" uuid NOT NULL, "sharedLinksId" uuid NOT NULL, CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId"))`);
await queryRunner.query(`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId") `);
await queryRunner.query(`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId") `);
await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`);
await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`);
await queryRunner.query(`DROP INDEX "public"."IDX_c9fab4aa97ffd1b034f3d6581a"`);
await queryRunner.query(`DROP INDEX "public"."IDX_5b7decce6c8d3db9593d6111a6"`);
await queryRunner.query(`DROP TABLE "shared_link__asset"`);
await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_key"`);
await queryRunner.query(`DROP TABLE "shared_links"`);
}
}

View file

@ -47,6 +47,7 @@
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1", "openid-client": "^5.2.1",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-custom": "^1.1.1",
"passport-http-header-strategy": "^1.1.0", "passport-http-header-strategy": "^1.1.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"pg": "^8.7.1", "pg": "^8.7.1",
@ -8619,6 +8620,17 @@
"url": "https://github.com/sponsors/jaredhanson" "url": "https://github.com/sponsors/jaredhanson"
} }
}, },
"node_modules/passport-custom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
"dependencies": {
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/passport-http-header-strategy": { "node_modules/passport-http-header-strategy": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
@ -17927,6 +17939,14 @@
"utils-merge": "^1.0.1" "utils-merge": "^1.0.1"
} }
}, },
"passport-custom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
"requires": {
"passport-strategy": "1.x.x"
}
},
"passport-http-header-strategy": { "passport-http-header-strategy": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",

View file

@ -70,6 +70,7 @@
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1", "openid-client": "^5.2.1",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-custom": "^1.1.1",
"passport-http-header-strategy": "^1.1.0", "passport-http-header-strategy": "^1.1.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"pg": "^8.7.1", "pg": "^8.7.1",

View file

@ -9,6 +9,7 @@ import {
JobApi, JobApi,
OAuthApi, OAuthApi,
ServerInfoApi, ServerInfoApi,
ShareApi,
SystemConfigApi, SystemConfigApi,
UserApi UserApi
} from './open-api'; } from './open-api';
@ -24,6 +25,7 @@ class ImmichApi {
public jobApi: JobApi; public jobApi: JobApi;
public keyApi: APIKeyApi; public keyApi: APIKeyApi;
public systemConfigApi: SystemConfigApi; public systemConfigApi: SystemConfigApi;
public shareApi: ShareApi;
private config = new Configuration({ basePath: '/api' }); private config = new Configuration({ basePath: '/api' });
@ -38,6 +40,7 @@ class ImmichApi {
this.jobApi = new JobApi(this.config); this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config); this.keyApi = new APIKeyApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config); this.systemConfigApi = new SystemConfigApi(this.config);
this.shareApi = new ShareApi(this.config);
} }
public setAccessToken(accessToken: string) { public setAccessToken(accessToken: string) {

View file

@ -671,6 +671,37 @@ export interface CreateAlbumDto {
*/ */
'assetIds'?: Array<string>; 'assetIds'?: Array<string>;
} }
/**
*
* @export
* @interface CreateAlbumShareLinkDto
*/
export interface CreateAlbumShareLinkDto {
/**
*
* @type {string}
* @memberof CreateAlbumShareLinkDto
*/
'albumId': string;
/**
*
* @type {string}
* @memberof CreateAlbumShareLinkDto
*/
'expiredAt'?: string;
/**
*
* @type {boolean}
* @memberof CreateAlbumShareLinkDto
*/
'allowUpload'?: boolean;
/**
*
* @type {string}
* @memberof CreateAlbumShareLinkDto
*/
'description'?: string;
}
/** /**
* *
* @export * @export
@ -918,6 +949,50 @@ export const DeviceTypeEnum = {
export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum]; export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum];
/**
*
* @export
* @interface DownloadFilesDto
*/
export interface DownloadFilesDto {
/**
*
* @type {Array<string>}
* @memberof DownloadFilesDto
*/
'assetIds': Array<string>;
}
/**
*
* @export
* @interface EditSharedLinkDto
*/
export interface EditSharedLinkDto {
/**
*
* @type {string}
* @memberof EditSharedLinkDto
*/
'description'?: string;
/**
*
* @type {string}
* @memberof EditSharedLinkDto
*/
'expiredAt'?: string;
/**
*
* @type {boolean}
* @memberof EditSharedLinkDto
*/
'allowUpload'?: boolean;
/**
*
* @type {boolean}
* @memberof EditSharedLinkDto
*/
'isEditExpireTime'?: boolean;
}
/** /**
* *
* @export * @export
@ -1477,6 +1552,87 @@ export interface ServerVersionReponseDto {
*/ */
'build': number; 'build': number;
} }
/**
*
* @export
* @interface SharedLinkResponseDto
*/
export interface SharedLinkResponseDto {
/**
*
* @type {SharedLinkType}
* @memberof SharedLinkResponseDto
*/
'type': SharedLinkType;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'id': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'description'?: string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'userId': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'key': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'createdAt': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'expiresAt': string | null;
/**
*
* @type {Array<string>}
* @memberof SharedLinkResponseDto
*/
'assets': Array<string>;
/**
*
* @type {AlbumResponseDto}
* @memberof SharedLinkResponseDto
*/
'album'?: AlbumResponseDto;
/**
*
* @type {boolean}
* @memberof SharedLinkResponseDto
*/
'allowUpload': boolean;
}
/**
*
* @export
* @enum {string}
*/
export const SharedLinkType = {
Album: 'ALBUM',
Individual: 'INDIVIDUAL'
} as const;
export type SharedLinkType = typeof SharedLinkType[keyof typeof SharedLinkType];
/** /**
* *
* @export * @export
@ -2554,6 +2710,45 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAlbumSharedLink: async (createAlbumShareLinkDto: CreateAlbumShareLinkDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createAlbumShareLinkDto' is not null or undefined
assertParamExists('createAlbumSharedLink', 'createAlbumShareLinkDto', createAlbumShareLinkDto)
const localVarPath = `/album/create-shared-link`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createAlbumShareLinkDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {string} albumId * @param {string} albumId
@ -2915,6 +3110,16 @@ export const AlbumApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbum(createAlbumDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbum(createAlbumDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbumSharedLink(createAlbumShareLinkDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {string} albumId * @param {string} albumId
@ -3038,6 +3243,15 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
createAlbum(createAlbumDto: CreateAlbumDto, options?: any): AxiosPromise<AlbumResponseDto> { createAlbum(createAlbumDto: CreateAlbumDto, options?: any): AxiosPromise<AlbumResponseDto> {
return localVarFp.createAlbum(createAlbumDto, options).then((request) => request(axios, basePath)); return localVarFp.createAlbum(createAlbumDto, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.createAlbumSharedLink(createAlbumShareLinkDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {string} albumId * @param {string} albumId
@ -3159,6 +3373,17 @@ export class AlbumApi extends BaseAPI {
return AlbumApiFp(this.configuration).createAlbum(createAlbumDto, options).then((request) => request(this.axios, this.basePath)); return AlbumApiFp(this.configuration).createAlbum(createAlbumDto, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AlbumApi
*/
public createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).createAlbumSharedLink(createAlbumShareLinkDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {string} albumId * @param {string} albumId
@ -3423,6 +3648,45 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFiles: async (downloadFilesDto: DownloadFilesDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'downloadFilesDto' is not null or undefined
assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto)
const localVarPath = `/asset/download-files`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {number} [skip] * @param {number} [skip]
@ -4050,6 +4314,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options); const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadFiles(downloadFilesDto: DownloadFilesDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {number} [skip] * @param {number} [skip]
@ -4248,6 +4522,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise<object> { downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise<object> {
return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath)); return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFiles(downloadFilesDto: DownloadFilesDto, options?: any): AxiosPromise<object> {
return localVarFp.downloadFiles(downloadFilesDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {number} [skip] * @param {number} [skip]
@ -4439,6 +4722,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public downloadFiles(downloadFilesDto: DownloadFilesDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadFiles(downloadFilesDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {number} [skip] * @param {number} [skip]
@ -6052,6 +6346,354 @@ export class ServerInfoApi extends BaseAPI {
} }
/**
* ShareApi - axios parameter creator
* @export
*/
export const ShareApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {string} id
* @param {EditSharedLinkDto} editSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
editSharedLink: async (id: string, editSharedLinkDto: EditSharedLinkDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('editSharedLink', 'id', id)
// verify required parameter 'editSharedLinkDto' is not null or undefined
assertParamExists('editSharedLink', 'editSharedLinkDto', editSharedLinkDto)
const localVarPath = `/share/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(editSharedLinkDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllSharedLinks: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/share`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMySharedLink: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/share/me`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSharedLinkById: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getSharedLinkById', 'id', id)
const localVarPath = `/share/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeSharedLink: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('removeSharedLink', 'id', id)
const localVarPath = `/share/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* ShareApi - functional programming interface
* @export
*/
export const ShareApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ShareApiAxiosParamCreator(configuration)
return {
/**
*
* @param {string} id
* @param {EditSharedLinkDto} editSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.editSharedLink(id, editSharedLinkDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllSharedLinks(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<SharedLinkResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllSharedLinks(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMySharedLink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSharedLinkById(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSharedLinkById(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeSharedLink(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* ShareApi - factory interface
* @export
*/
export const ShareApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ShareApiFp(configuration)
return {
/**
*
* @param {string} id
* @param {EditSharedLinkDto} editSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.editSharedLink(id, editSharedLinkDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllSharedLinks(options?: any): AxiosPromise<Array<SharedLinkResponseDto>> {
return localVarFp.getAllSharedLinks(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMySharedLink(options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.getMySharedLink(options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSharedLinkById(id: string, options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.getSharedLinkById(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeSharedLink(id: string, options?: any): AxiosPromise<string> {
return localVarFp.removeSharedLink(id, options).then((request) => request(axios, basePath));
},
};
};
/**
* ShareApi - object-oriented interface
* @export
* @class ShareApi
* @extends {BaseAPI}
*/
export class ShareApi extends BaseAPI {
/**
*
* @param {string} id
* @param {EditSharedLinkDto} editSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ShareApi
*/
public editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: AxiosRequestConfig) {
return ShareApiFp(this.configuration).editSharedLink(id, editSharedLinkDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ShareApi
*/
public getAllSharedLinks(options?: AxiosRequestConfig) {
return ShareApiFp(this.configuration).getAllSharedLinks(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ShareApi
*/
public getMySharedLink(options?: AxiosRequestConfig) {
return ShareApiFp(this.configuration).getMySharedLink(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ShareApi
*/
public getSharedLinkById(id: string, options?: AxiosRequestConfig) {
return ShareApiFp(this.configuration).getSharedLinkById(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ShareApi
*/
public removeSharedLink(id: string, options?: AxiosRequestConfig) {
return ShareApiFp(this.configuration).removeSharedLink(id, options).then((request) => request(this.axios, this.basePath));
}
}
/** /**
* SystemConfigApi - axios parameter creator * SystemConfigApi - axios parameter creator
* @export * @export

View file

@ -4,13 +4,14 @@ import { UserResponseDto } from './open-api';
const _basePath = '/api'; const _basePath = '/api';
export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) { export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean, key?: string) {
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file/${assetId}`); const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file/${assetId}`);
if (isThumb !== undefined && isThumb !== null) if (isThumb !== undefined && isThumb !== null)
urlObj.searchParams.append('isThumb', `${isThumb}`); urlObj.searchParams.append('isThumb', `${isThumb}`);
if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`); if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`);
if (key !== undefined && key !== null) urlObj.searchParams.append('key', key);
return urlObj.href; return urlObj.href;
} }

View file

@ -1,15 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<head> <body class="bg-immich-bg dark:bg-immich-dark-bg">
<meta charset="utf-8" /> <div>%sveltekit.body%</div>
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> </body>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body class="bg-immich-bg dark:bg-immich-dark-bg">
<div>%sveltekit.body%</div>
</body>
</html> </html>

View file

@ -93,7 +93,7 @@
>. >.
</p> </p>
<SettingSwitch title="Enable" bind:checked={oauthConfig.enabled} /> <SettingSwitch title="ENABLE" bind:checked={oauthConfig.enabled} />
<hr /> <hr />
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}

View file

@ -25,7 +25,7 @@
<div class="w-full"> <div class="w-full">
<div class={`flex place-items-center gap-1 h-[26px]`}> <div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-xs`} for={label}>{label}</label> <label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required} {#if required}
<div class="text-red-400">*</div> <div class="text-red-400">*</div>
{/if} {/if}

View file

@ -8,13 +8,13 @@
<div class="flex justify-between place-items-center"> <div class="flex justify-between place-items-center">
<div> <div>
<h2 class="immich-form-label text-sm"> <h2 class="immich-form-label text-sm">
{title.toUpperCase()} {title}
</h2> </h2>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p> <p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div> </div>
<label class="relative inline-block w-[36px] h-[10px]" {disabled}> <label class="relative inline-block w-[36px] h-[10px]">
<input <input
class="opacity-0 w-0 h-0 disabled::cursor-not-allowed" class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
type="checkbox" type="checkbox"

View file

@ -93,6 +93,7 @@ describe('AlbumCard component', () => {
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith( expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
'thumbnailIdOne', 'thumbnailIdOne',
ThumbnailFormat.Jpeg, ThumbnailFormat.Jpeg,
'',
{ responseType: 'blob' } { responseType: 'blob' }
); );
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob); expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);

View file

@ -1,7 +1,15 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { AlbumResponseDto, api, AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api'; import {
AlbumResponseDto,
api,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType,
ThumbnailFormat,
UserResponseDto
} from '@api';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import Plus from 'svelte-material-icons/Plus.svelte'; import Plus from 'svelte-material-icons/Plus.svelte';
@ -23,20 +31,30 @@
import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import ThumbnailSelection from './thumbnail-selection.svelte'; import ThumbnailSelection from './thumbnail-selection.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import { import {
notificationController, notificationController,
NotificationType NotificationType
} from '../shared-components/notification/notification'; } from '../shared-components/notification/notification';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import CreateSharedLinkModal from '../shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { bulkDownload } from '$lib/utils/asset-utils';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore; const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
let isShowAssetViewer = false; let isShowAssetViewer = false;
let isShowAssetSelection = false; let isShowAssetSelection = false;
let isShowShareLinkModal = false;
$: $isAlbumAssetSelectionOpen = isShowAssetSelection; $: $isAlbumAssetSelectionOpen = isShowAssetSelection;
$: { $: {
if (browser) { if (browser) {
@ -65,6 +83,7 @@
let titleInput: HTMLInputElement; let titleInput: HTMLInputElement;
let contextMenuPosition = { x: 0, y: 0 }; let contextMenuPosition = { x: 0, y: 0 };
$: isPublicShared = sharedLink;
$: isOwned = currentUser?.id == album.ownerId; $: isOwned = currentUser?.id == album.ownerId;
let multiSelectAsset: Set<AssetResponseDto> = new Set(); let multiSelectAsset: Set<AssetResponseDto> = new Set();
@ -82,7 +101,11 @@
if (album.assets?.length < 6) { if (album.assets?.length < 6) {
thumbnailSize = Math.floor(viewWidth / album.assetCount - album.assetCount); thumbnailSize = Math.floor(viewWidth / album.assetCount - album.assetCount);
} else { } else {
thumbnailSize = Math.floor(viewWidth / 6 - 6); if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6);
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);
else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6);
else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6);
else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6);
} }
} }
@ -219,9 +242,17 @@
const createAlbumHandler = async (event: CustomEvent) => { const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: AssetResponseDto[] } = event.detail; const { assets }: { assets: AssetResponseDto[] } = event.detail;
try { try {
const { data } = await api.albumApi.addAssetsToAlbum(album.id, { const { data } = await api.albumApi.addAssetsToAlbum(
assetIds: assets.map((a) => a.id) album.id,
}); {
assetIds: assets.map((a) => a.id)
},
{
params: {
key: sharedLink?.key
}
}
);
if (data.album) { if (data.album) {
album = data.album; album = data.album;
@ -316,6 +347,9 @@
album.id, album.id,
skip || undefined, skip || undefined,
{ {
params: {
key: sharedLink?.key
},
responseType: 'blob', responseType: 'blob',
onDownloadProgress: function (progressEvent) { onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest; const request = this as XMLHttpRequest;
@ -397,6 +431,23 @@
isShowThumbnailSelection = false; isShowThumbnailSelection = false;
}; };
const onSharedLinkClickHandler = () => {
isShowShareUserSelection = false;
isShowShareLinkModal = true;
};
const handleDownloadSelectedAssets = async () => {
await bulkDownload(
album.albumName,
Array.from(multiSelectAsset),
() => {
isMultiSelectionMode = false;
clearMultiSelectAssetAssetHandler();
},
sharedLink?.key
);
};
</script> </script>
<section class="bg-immich-bg dark:bg-immich-dark-bg"> <section class="bg-immich-bg dark:bg-immich-dark-bg">
@ -413,6 +464,11 @@
</p> </p>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
<CircleIconButton
title="Download"
on:click={handleDownloadSelectedAssets}
logo={CloudDownloadOutline}
/>
{#if isOwned} {#if isOwned}
<CircleIconButton <CircleIconButton
title="Remove from album" title="Remove from album"
@ -426,14 +482,45 @@
<!-- Default app bar --> <!-- Default app bar -->
{#if !isMultiSelectionMode} {#if !isMultiSelectionMode}
<ControlAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}> <ControlAppBar
on:close-button-click={() => goto(backUrl)}
backIcon={ArrowLeft}
showBackButton={(!isPublicShared && isOwned) ||
(!isPublicShared && !isOwned) ||
(isPublicShared && isOwned)}
>
<svelte:fragment slot="leading">
{#if isPublicShared && !isOwned}
<a
data-sveltekit-preload-data="hover"
class="flex gap-2 place-items-center hover:cursor-pointer ml-6"
href="https://immich.app"
>
<img src="/immich-logo.svg" alt="immich logo" height="30" width="30" />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">
IMMICH
</h1>
</a>
{/if}
</svelte:fragment>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
{#if album.assetCount > 0} {#if album.assetCount > 0}
<CircleIconButton {#if !sharedLink}
title="Add Photos" <CircleIconButton
on:click={() => (isShowAssetSelection = true)} title="Add Photos"
logo={FileImagePlusOutline} on:click={() => (isShowAssetSelection = true)}
/> logo={FileImagePlusOutline}
/>
{/if}
{#if sharedLink?.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id, sharedLink?.key)}
logo={FileImagePlusOutline}
/>
{/if}
<!-- Share and remove album --> <!-- Share and remove album -->
{#if isOwned} {#if isOwned}
@ -451,11 +538,17 @@
logo={FolderDownloadOutline} logo={FolderDownloadOutline}
/> />
<CircleIconButton {#if !isPublicShared}
title="Album options" <CircleIconButton
on:click={(event) => showAlbumOptionsMenu(event)} title="Album options"
logo={DotsVertical} on:click={(event) => showAlbumOptionsMenu(event)}
/> logo={DotsVertical}
/>
{/if}
{#if isPublicShared}
<ThemeButton />
{/if}
{/if} {/if}
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0} {#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
@ -470,7 +563,7 @@
</ControlAppBar> </ControlAppBar>
{/if} {/if}
<section class="m-auto my-[160px] w-[60%]"> <section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<input <input
on:keydown={(e) => { on:keydown={(e) => {
if (e.key == 'Enter') { if (e.key == 'Enter') {
@ -492,7 +585,6 @@
{#if album.assetCount > 0} {#if album.assetCount > 0}
<p class="my-4 text-sm text-gray-500 font-medium">{getDateRange()}</p> <p class="my-4 text-sm text-gray-500 font-medium">{getDateRange()}</p>
{/if} {/if}
{#if album.shared} {#if album.shared}
<div class="my-6 flex"> <div class="my-6 flex">
{#each album.sharedUsers as user} {#each album.sharedUsers as user}
@ -521,6 +613,7 @@
<ImmichThumbnail <ImmichThumbnail
{asset} {asset}
{thumbnailSize} {thumbnailSize}
publicSharedKey={sharedLink?.key}
format={ThumbnailFormat.Jpeg} format={ThumbnailFormat.Jpeg}
on:click={(e) => on:click={(e) =>
isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)} isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
@ -531,6 +624,7 @@
<ImmichThumbnail <ImmichThumbnail
{asset} {asset}
{thumbnailSize} {thumbnailSize}
publicSharedKey={sharedLink?.key}
on:click={(e) => on:click={(e) =>
isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)} isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
on:select={selectAssetHandler} on:select={selectAssetHandler}
@ -564,6 +658,7 @@
{#if isShowAssetViewer} {#if isShowAssetViewer}
<AssetViewer <AssetViewer
asset={selectedAsset} asset={selectedAsset}
publicSharedKey={sharedLink?.key}
on:navigate-previous={navigateAssetBackward} on:navigate-previous={navigateAssetBackward}
on:navigate-next={navigateAssetForward} on:navigate-next={navigateAssetForward}
on:close={closeViewer} on:close={closeViewer}
@ -581,12 +676,21 @@
{#if isShowShareUserSelection} {#if isShowShareUserSelection}
<UserSelectionModal <UserSelectionModal
{album}
on:close={() => (isShowShareUserSelection = false)} on:close={() => (isShowShareUserSelection = false)}
on:add-user={addUserHandler} on:add-user={addUserHandler}
on:sharedlinkclick={onSharedLinkClickHandler}
sharedUsersInAlbum={new Set(album.sharedUsers)} sharedUsersInAlbum={new Set(album.sharedUsers)}
/> />
{/if} {/if}
{#if isShowShareLinkModal}
<CreateSharedLinkModal
on:close={() => (isShowShareLinkModal = false)}
shareType={SharedLinkType.Album}
{album}
/>
{/if}
{#if isShowShareInfoModal} {#if isShowShareInfoModal}
<ShareInfoModal <ShareInfoModal
on:close={() => (isShowShareInfoModal = false)} on:close={() => (isShowShareInfoModal = false)}

View file

@ -51,7 +51,7 @@
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
<button <button
on:click={() => on:click={() =>
openFileUploadDialog(albumId, () => { openFileUploadDialog(albumId, '', () => {
assetInteractionStore.clearMultiselect(); assetInteractionStore.clearMultiselect();
dispatch('go-back'); dispatch('go-back');
})} })}

View file

@ -1,16 +1,21 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { api, UserResponseDto } from '@api'; import { AlbumResponseDto, api, SharedLinkResponseDto, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte'; import BaseModal from '../shared-components/base-modal.svelte';
import CircleAvatar from '../shared-components/circle-avatar.svelte'; import CircleAvatar from '../shared-components/circle-avatar.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import ShareCircle from 'svelte-material-icons/ShareCircle.svelte';
import { goto } from '$app/navigation';
export let album: AlbumResponseDto;
export let sharedUsersInAlbum: Set<UserResponseDto>; export let sharedUsersInAlbum: Set<UserResponseDto>;
let users: UserResponseDto[] = []; let users: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = []; let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => { onMount(async () => {
await getSharedLinks();
const { data } = await api.userApi.getAllUsers(false); const { data } = await api.userApi.getAllUsers(false);
// remove soft deleted users // remove soft deleted users
@ -22,6 +27,12 @@
}); });
}); });
const getSharedLinks = async () => {
const { data } = await api.shareApi.getAllSharedLinks();
sharedLinks = data.filter((link) => link.album?.id === album.id);
};
const selectUser = (user: UserResponseDto) => { const selectUser = (user: UserResponseDto) => {
if (selectedUsers.includes(user)) { if (selectedUsers.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
@ -33,6 +44,10 @@
const deselectUser = (user: UserResponseDto) => { const deselectUser = (user: UserResponseDto) => {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
}; };
const onSharedLinkClick = () => {
dispatch('sharedlinkclick');
};
</script> </script>
<BaseModal on:close={() => dispatch('close')}> <BaseModal on:close={() => dispatch('close')}>
@ -93,7 +108,7 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="text-sm px-5"> <p class="text-sm p-5">
Looks like you have shared this album with all users or you don't have any user to share Looks like you have shared this album with all users or you don't have any user to share
with. with.
</p> </p>
@ -109,4 +124,25 @@
</div> </div>
{/if} {/if}
</div> </div>
<hr />
<div id="shared-buttons" class="flex my-4 justify-around place-items-center place-content-center">
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={onSharedLinkClick}
>
<Link size={24} />
<p class="text-sm">Create link</p>
</button>
{#if sharedLinks.length}
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={() => goto('/sharing/sharedlinks')}
>
<ShareCircle size={24} />
<p class="text-sm">View links</p>
</button>
{/if}
</div>
</BaseModal> </BaseModal>

View file

@ -23,7 +23,7 @@
export let showMotionPlayButton: boolean; export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false; export let isMotionPhotoPlaying = false;
const isOwner = asset.ownerId === $page.data.user.id; const isOwner = asset.ownerId === $page.data.user?.id;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -94,12 +94,15 @@
title="Favorite" title="Favorite"
/> />
{/if} {/if}
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
<CircleIconButton {#if isOwner}
logo={DotsVertical} <CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
on:click={(event) => showOptionsMenu(event)} <CircleIconButton
title="More" logo={DotsVertical}
/> on:click={(event) => showOptionsMenu(event)}
title="More"
/>
{/if}
</div> </div>
</div> </div>

View file

@ -10,12 +10,7 @@
import { downloadAssets } from '$lib/stores/download'; import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte'; import VideoViewer from './video-viewer.svelte';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte'; import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
import { import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api';
api,
AssetResponseDto,
AssetTypeEnum,
AlbumResponseDto
} from '@api';
import { import {
notificationController, notificationController,
NotificationType NotificationType
@ -25,6 +20,9 @@
import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum } from '$lib/utils/asset-utils';
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
export let publicSharedKey = '';
export let showNavigation = true;
$: { $: {
appearsInAlbums = []; appearsInAlbums = [];
@ -91,12 +89,12 @@
const handleDownload = () => { const handleDownload = () => {
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
downloadFile(asset.livePhotoVideoId, true); downloadFile(asset.livePhotoVideoId, true, publicSharedKey);
downloadFile(asset.id, false); downloadFile(asset.id, false, publicSharedKey);
return; return;
} }
downloadFile(asset.id, false); downloadFile(asset.id, false, publicSharedKey);
}; };
/** /**
@ -111,7 +109,7 @@
}; };
}; };
const downloadFile = async (assetId: string, isLivePhoto: boolean) => { const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => {
try { try {
const { filenameWithoutExtension } = getTemplateFilename(); const { filenameWithoutExtension } = getTemplateFilename();
@ -126,6 +124,9 @@
$downloadAssets[imageFileName] = 0; $downloadAssets[imageFileName] = 0;
const { data, status } = await api.assetApi.downloadFile(assetId, false, false, { const { data, status } = await api.assetApi.downloadFile(assetId, false, false, {
params: {
key
},
responseType: 'blob', responseType: 'blob',
onDownloadProgress: (progressEvent) => { onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) { if (progressEvent.lengthComputable) {
@ -251,69 +252,74 @@
/> />
</div> </div>
<div {#if showNavigation}
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${ <div
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]' class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${
}`} asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
on:mouseenter={() => { }`}
halfLeftHover = true; on:mouseenter={() => {
halfRightHover = false; halfLeftHover = true;
}} halfRightHover = false;
on:mouseleave={() => { }}
halfLeftHover = false; on:mouseleave={() => {
}} halfLeftHover = false;
on:click={navigateAssetBackward} }}
on:keydown={navigateAssetBackward}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward} on:click={navigateAssetBackward}
on:keydown={navigateAssetBackward}
> >
<ChevronLeft size="36" /> <button
</button> class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
</div> class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward}
>
<ChevronLeft size="36" />
</button>
</div>
{/if}
<div class="row-start-1 row-span-full col-start-1 col-span-4"> <div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key asset.id} {#key asset.id}
{#if asset.type === AssetTypeEnum.Image} {#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId} {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer <VideoViewer
{publicSharedKey}
assetId={asset.livePhotoVideoId} assetId={asset.livePhotoVideoId}
on:close={closeViewer} on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/> />
{:else} {:else}
<PhotoViewer assetId={asset.id} on:close={closeViewer} /> <PhotoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
{/if} {/if}
{:else} {:else}
<VideoViewer assetId={asset.id} on:close={closeViewer} /> <VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
{/if} {/if}
{/key} {/key}
</div> </div>
<div {#if showNavigation}
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${ <div
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]' class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${
}`} asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
on:click={navigateAssetForward} }`}
on:keydown={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
halfRightHover = false;
}}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward} on:click={navigateAssetForward}
on:keydown={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
halfRightHover = false;
}}
> >
<ChevronRight size="36" /> <button
</button> class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
</div> class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward}
>
<ChevronRight size="36" />
</button>
</div>
{/if}
{#if isShowDetail} {#if isShowDetail}
<div <div

View file

@ -11,6 +11,7 @@
} from '../shared-components/notification/notification'; } from '../shared-components/notification/notification';
export let assetId: string; export let assetId: string;
export let publicSharedKey = '';
let assetInfo: AssetResponseDto; let assetInfo: AssetResponseDto;
let assetData: string; let assetData: string;
@ -18,7 +19,11 @@
let copyImageToClipboard: (src: string) => Promise<Blob>; let copyImageToClipboard: (src: string) => Promise<Blob>;
onMount(async () => { onMount(async () => {
const { data } = await api.assetApi.getAssetById(assetId); const { data } = await api.assetApi.getAssetById(assetId, {
params: {
key: publicSharedKey
}
});
assetInfo = data; assetInfo = data;
//Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 //Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
@ -29,6 +34,9 @@
const loadAssetData = async () => { const loadAssetData = async () => {
try { try {
const { data } = await api.assetApi.serveFile(assetInfo.id, false, true, { const { data } = await api.assetApi.serveFile(assetInfo.id, false, true, {
params: {
key: publicSharedKey
},
responseType: 'blob' responseType: 'blob'
}); });

View file

@ -6,7 +6,7 @@
import { api, AssetResponseDto, getFileUrl } from '@api'; import { api, AssetResponseDto, getFileUrl } from '@api';
export let assetId: string; export let assetId: string;
export let publicSharedKey = '';
let asset: AssetResponseDto; let asset: AssetResponseDto;
let videoPlayerNode: HTMLVideoElement; let videoPlayerNode: HTMLVideoElement;
@ -15,7 +15,11 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(async () => { onMount(async () => {
const { data: assetInfo } = await api.assetApi.getAssetById(assetId); const { data: assetInfo } = await api.assetApi.getAssetById(assetId, {
params: {
key: publicSharedKey
}
});
await loadVideoData(assetInfo); await loadVideoData(assetInfo);
@ -25,7 +29,7 @@
const loadVideoData = async (assetInfo: AssetResponseDto) => { const loadVideoData = async (assetInfo: AssetResponseDto) => {
isVideoLoading = true; isVideoLoading = true;
videoUrl = getFileUrl(assetInfo.id, false, true); videoUrl = getFileUrl(assetInfo.id, false, true, publicSharedKey);
return assetInfo; return assetInfo;
}; };

View file

@ -5,6 +5,8 @@
import Close from 'svelte-material-icons/Close.svelte'; import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '../shared-components/circle-icon-button.svelte'; import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
export let showBackButton = true;
export let backIcon = Close; export let backIcon = Close;
export let tailwindClasses = ''; export let tailwindClasses = '';
@ -42,14 +44,15 @@
class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`} class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`}
> >
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg"> <div class="flex place-items-center gap-6 dark:text-immich-dark-fg">
<CircleIconButton {#if showBackButton}
on:click={() => dispatch('close-button-click')} <CircleIconButton
logo={backIcon} on:click={() => dispatch('close-button-click')}
backgroundColor={'transparent'} logo={backIcon}
hoverColor={'#e2e7e9'} backgroundColor={'transparent'}
size={'24'} hoverColor={'#e2e7e9'}
/> size={'24'}
/>
{/if}
<slot name="leading" /> <slot name="leading" />
</div> </div>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import BaseModal from '../base-modal.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import { AlbumResponseDto, api, SharedLinkResponseDto, SharedLinkType } from '@api';
import { notificationController, NotificationType } from '../notification/notification';
import { ImmichDropDownOption } from '../dropdown-button.svelte';
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import DropdownButton from '../dropdown-button.svelte';
import SettingInputField, {
SettingInputFieldType
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
export let shareType: SharedLinkType;
export let album: AlbumResponseDto | undefined;
export let editingLink: SharedLinkResponseDto | undefined = undefined;
let isLoading = false;
let isShowSharedLink = false;
let expirationTime = '';
let isAllowUpload = false;
let sharedLink = '';
let description = '';
let shouldChangeExpirationTime = false;
const dispatch = createEventDispatcher();
const expiredDateOption: ImmichDropDownOption = {
default: 'Never',
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days']
};
onMount(() => {
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
}
isAllowUpload = editingLink.allowUpload;
}
});
const createAlbumSharedLink = async () => {
if (album) {
isLoading = true;
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
const { data } = await api.albumApi.createAlbumSharedLink({
albumId: album.id,
expiredAt: expirationDate,
allowUpload: isAllowUpload,
description: description
});
buildSharedLink(data);
isLoading = false;
isShowSharedLink = true;
} catch (e) {
console.error('[createAlbumSharedLink] Error: ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Failed to create shared link'
});
isLoading = false;
}
}
};
const buildSharedLink = (createdLink: SharedLinkResponseDto) => {
sharedLink = `${window.location.origin}/share/${createdLink.key}`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(sharedLink);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info
});
} catch (error) {
console.error('Error', error);
}
};
const getExpirationTimeInMillisecond = () => {
switch (expirationTime) {
case '30 minutes':
return 30 * 60 * 1000;
case '1 hour':
return 60 * 60 * 1000;
case '6 hours':
return 6 * 60 * 60 * 1000;
case '1 day':
return 24 * 60 * 60 * 1000;
case '7 days':
return 7 * 24 * 60 * 60 * 1000;
case '30 days':
return 30 * 24 * 60 * 60 * 1000;
default:
return 0;
}
};
const handleEditLink = async () => {
if (editingLink) {
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
let expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
if (expirationTime === 0) {
expirationDate = undefined;
}
await api.shareApi.editSharedLink(editingLink.id, {
description: description,
expiredAt: expirationDate,
allowUpload: isAllowUpload,
isEditExpireTime: shouldChangeExpirationTime
});
notificationController.show({
type: NotificationType.Info,
message: 'Edited'
});
dispatch('close');
} catch (e) {
console.error('[handleEditLink]', e);
notificationController.show({
type: NotificationType.Error,
message: 'Failed to edit shared link'
});
}
}
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<Link size={24} />
{#if editingLink}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Edit link</p>
{:else}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Create link to share</p>
{/if}
</span>
</svelte:fragment>
<section class="mx-6 mb-6">
{#if shareType == SharedLinkType.Album}
{#if !editingLink}
<div>Let anyone with the link see photos and people in this album.</div>
{:else}
<div class="text-sm">
Public album | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.album?.albumName}</span
>
</div>
{/if}
{/if}
<div class="mt-6 mb-2">
<p class="text-xs">LINK OPTIONS</p>
</div>
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
<div class="flex flex-col">
<div class="mb-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Description"
bind:value={description}
/>
</div>
<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} />
<div class="text-sm mt-4">
{#if editingLink}
<p class="my-2 immich-form-label">
<SettingSwitch
bind:checked={shouldChangeExpirationTime}
title={'Change expiration time'}
/>
</p>
{:else}
<p class="my-2 immich-form-label">Expire after</p>
{/if}
<DropdownButton
options={expiredDateOption}
bind:selected={expirationTime}
disabled={editingLink && !shouldChangeExpirationTime}
/>
</div>
</div>
</div>
</section>
<hr />
<section class="m-6">
{#if !isShowSharedLink}
{#if editingLink}
<div class="flex justify-end">
<button
on:click={handleEditLink}
class="text-white dark:text-black bg-immich-primary px-4 py-2 rounded-lg text-sm transition-colors hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:hover:bg-immich-dark-primary/75"
>
Confirm
</button>
</div>
{:else}
<div class="flex justify-end">
<button
on:click={createAlbumSharedLink}
class="text-white dark:text-black bg-immich-primary px-4 py-2 rounded-lg text-sm transition-colors hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:hover:bg-immich-dark-primary/75"
>
Create Link
</button>
</div>
{/if}
{/if}
{#if isShowSharedLink}
<div class="flex w-full gap-4">
<input class="immich-form-input w-full" bind:value={sharedLink} />
<button
on:click={() => handleCopy()}
class="flex-1 transition-colors bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-2 text-white rounded-full shadow-md w-full font-medium"
>Copy</button
>
</div>
{/if}
</section>
</BaseModal>

View file

@ -0,0 +1,76 @@
<script lang="ts" context="module">
export type ImmichDropDownOption = {
default: string;
options: string[];
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
export let options: ImmichDropDownOption;
export let selected: string;
export let disabled = false;
onMount(() => {
selected = options.default;
});
export let isOpen = false;
const toggle = () => (isOpen = !isOpen);
</script>
<div id="immich-dropdown" class="relative">
<button
{disabled}
on:click={toggle}
aria-expanded={isOpen}
class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600 "
>
<div>
{selected}
</div>
<div>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{#if isOpen}
<div class="flex flex-col mt-2 absolute w-full">
{#each options.options as option}
<button
on:click={() => {
selected = option;
isOpen = false;
}}
class="bg-gray-200 dark:bg-gray-500 dark:hover:bg-gray-700 w-full flex p-2 hover:bg-gray-300 transition-all "
>
{option}
</button>
{/each}
</div>
{/if}
</div>
<style>
svg {
transition: transform 0.2s ease-in;
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
</style>

View file

@ -18,6 +18,9 @@
export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false; export let selected = false;
export let disabled = false; export let disabled = false;
export let publicSharedKey = '';
export let isRoundedCorner = false;
let imageData: string; let imageData: string;
let mouseOver = false; let mouseOver = false;
@ -35,10 +38,9 @@
isThumbnailVideoPlaying = false; isThumbnailVideoPlaying = false;
if (isLivePhoto && asset.livePhotoVideoId) { if (isLivePhoto && asset.livePhotoVideoId) {
console.log('get file url'); videoUrl = getFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey);
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
} else { } else {
videoUrl = getFileUrl(asset.id, false, true); videoUrl = getFileUrl(asset.id, false, true, publicSharedKey);
} }
}; };
@ -118,6 +120,8 @@
return 'border-[20px] border-immich-primary/20'; return 'border-[20px] border-immich-primary/20';
} else if (disabled) { } else if (disabled) {
return 'border-[20px] border-gray-300'; return 'border-[20px] border-gray-300';
} else if (isRoundedCorner) {
return 'rounded-[20px]';
} else { } else {
return ''; return '';
} }
@ -244,7 +248,7 @@
style:width={`${thumbnailSize}px`} style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`} style:height={`${thumbnailSize}px`}
in:fade={{ duration: 150 }} in:fade={{ duration: 150 }}
src={`/api/asset/thumbnail/${asset.id}?format=${format}`} src={`/api/asset/thumbnail/${asset.id}?format=${format}&key=${publicSharedKey}`}
alt={asset.id} alt={asset.id}
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`} class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
loading="lazy" loading="lazy"

View file

@ -49,7 +49,7 @@
on:click={toggleTheme} on:click={toggleTheme}
id="theme-toggle" id="theme-toggle"
type="button" type="button"
class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-lg text-sm p-2.5" class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-full text-sm p-2.5"
> >
<svg <svg
id="theme-toggle-dark-icon" id="theme-toggle-dark-icon"

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import OpenInNew from 'svelte-material-icons/OpenInNew.svelte';
import Delete from 'svelte-material-icons/TrashCanOutline.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import CircleEditOutline from 'svelte-material-icons/CircleEditOutline.svelte';
import * as luxon from 'luxon';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
export let link: SharedLinkResponseDto;
let expirationCountdown: luxon.DurationObjectUnits;
const dispatch = createEventDispatcher();
const getAssetInfo = async (): Promise<AssetResponseDto> => {
let assetId = '';
if (link.album?.albumThumbnailAssetId) {
assetId = link.album.albumThumbnailAssetId;
} else if (link.assets.length > 0) {
assetId = link.assets[0];
}
const { data } = await api.assetApi.getAssetById(assetId);
return data;
};
const getCountDownExpirationDate = () => {
if (!link.expiresAt) {
return;
}
const expiresAtDate = luxon.DateTime.fromISO(new Date(link.expiresAt).toISOString());
const now = luxon.DateTime.now();
expirationCountdown = expiresAtDate
.diff(now, ['days', 'hours', 'minutes', 'seconds'])
.toObject();
if (expirationCountdown.days && expirationCountdown.days > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'days' });
} else if (expirationCountdown.hours && expirationCountdown.hours > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'hours' });
} else if (expirationCountdown.minutes && expirationCountdown.minutes > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'minutes' });
} else if (expirationCountdown.seconds && expirationCountdown.seconds > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'seconds' });
}
};
const isExpired = (expiresAt: string) => {
const now = new Date().getTime();
const expiration = new Date(expiresAt).getTime();
return now > expiration;
};
</script>
<div
class="w-full flex gap-4 dark:text-immich-gray transition-all border-b border-gray-200 dark:border-gray-600 hover:border-immich-primary dark:hover:border-immich-dark-primary py-4"
>
<div>
{#await getAssetInfo()}
<LoadingSpinner />
{:then asset}
<img
id={asset.id}
src={`/api/asset/thumbnail/${asset.id}?format=WEBP`}
alt={asset.id}
class="object-cover w-[100px] h-[100px] rounded-lg"
loading="lazy"
/>
{/await}
</div>
<div class="flex flex-col justify-between">
<div class="info-top">
<div class="text-xs font-mono font-semibold text-gray-500 dark:text-gray-400">
{#if link.expiresAt}
{#if isExpired(link.expiresAt)}
<p class="text-red-600 dark:text-red-400 font-bold">Expired</p>
{:else}
<p>
Expires {getCountDownExpirationDate()}
</p>
{/if}
{:else}
<p>Expires ∞</p>
{/if}
</div>
<div class="text-sm">
<div
class="flex gap-2 place-items-center text-immich-primary dark:text-immich-dark-primary"
>
{#if link.type === SharedLinkType.Album}
<p>
{link.album?.albumName.toUpperCase()}
</p>
{:else if link.type === SharedLinkType.Individual}
<p>INDIVIDUAL SHARE</p>
{/if}
{#if !link.expiresAt || !isExpired(link.expiresAt)}
<div
class="hover:cursor-pointer"
title="Go to share page"
on:click={() => goto(`/share/${link.key}`)}
on:keydown={() => goto(`/share/${link.key}`)}
>
<OpenInNew />
</div>
{/if}
</div>
<p class="text-sm">{link.description ?? ''}</p>
</div>
</div>
<div class="info-bottom">
{#if link.allowUpload}
<div
class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[100px]"
>
Allow upload
</div>
{/if}
</div>
</div>
<div class="flex-auto flex flex-col place-content-center place-items-end text-right">
<div class="flex">
<CircleIconButton logo={Delete} on:click={() => dispatch('delete')} />
<CircleIconButton logo={CircleEditOutline} on:click={() => dispatch('edit')} />
<CircleIconButton logo={ContentCopy} on:click={() => dispatch('copy')} />
</div>
</div>
</div>

View file

@ -1,21 +1,106 @@
import { api, AddAssetsResponseDto } from '@api'; import { api, AddAssetsResponseDto, AssetResponseDto } from '@api';
import { import {
notificationController, notificationController,
NotificationType NotificationType
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { downloadAssets } from '$lib/stores/download';
import { get } from 'svelte/store';
export const addAssetsToAlbum = async ( export const addAssetsToAlbum = async (
albumId: string, albumId: string,
assetIds: Array<string> assetIds: Array<string>,
key: string | undefined = undefined
): Promise<AddAssetsResponseDto> => ): Promise<AddAssetsResponseDto> =>
api.albumApi.addAssetsToAlbum(albumId, { assetIds }).then(({ data: dto }) => { api.albumApi
if (dto.successfullyAdded > 0) { .addAssetsToAlbum(albumId, { assetIds }, { params: { key } })
// This might be 0 if the user tries to add an asset that is already in the album .then(({ data: dto }) => {
notificationController.show({ if (dto.successfullyAdded > 0) {
message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, // This might be 0 if the user tries to add an asset that is already in the album
type: NotificationType.Info notificationController.show({
}); message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
} type: NotificationType.Info
});
}
return dto; return dto;
}); });
export async function bulkDownload(
fileName: string,
assets: AssetResponseDto[],
onDone: () => void,
key?: string
) {
const assetIds = assets.map((asset) => asset.id);
try {
let skip = 0;
let count = 0;
let done = false;
while (!done) {
count++;
const downloadFileName = fileName + `${count === 1 ? '' : count}.zip`;
downloadAssets.set({ [downloadFileName]: 0 });
let total = 0;
const { data, status, headers } = await api.assetApi.downloadFiles(
{ assetIds },
{
params: { key },
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;
if (!total) {
total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0;
}
if (total) {
const current = progressEvent.loaded;
downloadAssets.set({ [downloadFileName]: Math.floor((current / total) * 100) });
}
}
}
);
const isNotComplete = headers['x-immich-archive-complete'] === 'false';
const fileCount = Number(headers['x-immich-archive-file-count']) || 0;
if (isNotComplete && fileCount > 0) {
skip += fileCount;
} else {
onDone();
done = true;
}
if (!(data instanceof Blob)) {
return;
}
if (status === 201) {
const fileUrl = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = fileUrl;
anchor.download = downloadFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(fileUrl);
// Remove item from download list
setTimeout(() => {
downloadAssets.set({});
}, 2000);
}
}
} catch (e) {
console.error('Error downloading file ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error downloading file, check console for more details.'
});
}
}

View file

@ -11,6 +11,7 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils';
export const openFileUploadDialog = ( export const openFileUploadDialog = (
albumId: string | undefined = undefined, albumId: string | undefined = undefined,
sharedKey: string | undefined = undefined,
callback?: () => void callback?: () => void
) => { ) => {
try { try {
@ -27,7 +28,7 @@ export const openFileUploadDialog = (
} }
const files = Array.from<File>(target.files); const files = Array.from<File>(target.files);
await fileUploadHandler(files, albumId); await fileUploadHandler(files, albumId, sharedKey);
callback && callback(); callback && callback();
}; };
@ -37,7 +38,11 @@ export const openFileUploadDialog = (
} }
}; };
export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined) => { export const fileUploadHandler = async (
files: File[],
albumId: string | undefined = undefined,
sharedKey: string | undefined = undefined
) => {
if (files.length > 50) { if (files.length > 50) {
notificationController.show({ notificationController.show({
type: NotificationType.Error, type: NotificationType.Error,
@ -49,18 +54,22 @@ export const fileUploadHandler = async (files: File[], albumId: string | undefin
return; return;
} }
console.log('fileUploadHandler');
const acceptedFile = files.filter( const acceptedFile = files.filter(
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image' (e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
); );
for (const asset of acceptedFile) { for (const asset of acceptedFile) {
await fileUploader(asset, albumId); await fileUploader(asset, albumId, sharedKey);
} }
}; };
//TODO: should probably use the @api SDK //TODO: should probably use the @api SDK
async function fileUploader(asset: File, albumId: string | undefined = undefined) { async function fileUploader(
asset: File,
albumId: string | undefined = undefined,
sharedKey: string | undefined = undefined
) {
const assetType = asset.type.split('/')[0].toUpperCase(); const assetType = asset.type.split('/')[0].toUpperCase();
const temp = asset.name.split('.'); const temp = asset.name.split('.');
const fileExtension = temp[temp.length - 1]; const fileExtension = temp[temp.length - 1];
@ -108,10 +117,17 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
formData.append('assetData', asset); formData.append('assetData', asset);
// Check if asset upload on server before performing upload // Check if asset upload on server before performing upload
const { data, status } = await api.assetApi.checkDuplicateAsset({ const { data, status } = await api.assetApi.checkDuplicateAsset(
deviceAssetId: String(deviceAssetId), {
deviceId: 'WEB' deviceAssetId: String(deviceAssetId),
}); deviceId: 'WEB'
},
{
params: {
key: sharedKey
}
}
);
if (status === 200) { if (status === 200) {
if (data.isExist) { if (data.isExist) {
@ -124,7 +140,6 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
} }
const request = new XMLHttpRequest(); const request = new XMLHttpRequest();
request.upload.onloadstart = () => { request.upload.onloadstart = () => {
const newUploadAsset: UploadAsset = { const newUploadAsset: UploadAsset = {
id: deviceAssetId, id: deviceAssetId,
@ -144,7 +159,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
try { try {
const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}'); const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}');
if (res.id) { if (res.id) {
addAssetsToAlbum(albumId, [res.id]); addAssetsToAlbum(albumId, [res.id], sharedKey);
} }
} catch (e) { } catch (e) {
console.error('ERROR parsing data JSON in upload onload'); console.error('ERROR parsing data JSON in upload onload');
@ -171,7 +186,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
uploadAssetsStore.updateProgress(deviceAssetId, percentComplete); uploadAssetsStore.updateProgress(deviceAssetId, percentComplete);
}; };
request.open('POST', `/api/asset/upload`); request.open('POST', `/api/asset/upload?key=${sharedKey ?? ''}`);
request.send(formData); request.send(formData);
} catch (e) { } catch (e) {

View file

@ -17,6 +17,7 @@
} from '$lib/stores/asset-interaction.store'; } from '$lib/stores/asset-interaction.store';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import Close from 'svelte-material-icons/Close.svelte'; import Close from 'svelte-material-icons/Close.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import Plus from 'svelte-material-icons/Plus.svelte'; import Plus from 'svelte-material-icons/Plus.svelte';
@ -26,7 +27,7 @@
NotificationType NotificationType
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { assetStore } from '$lib/stores/assets.store'; import { assetStore } from '$lib/stores/assets.store';
import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, bulkDownload } from '$lib/utils/asset-utils';
export let data: PageData; export let data: PageData;
@ -106,6 +107,12 @@
assetInteractionStore.clearMultiselect(); assetInteractionStore.clearMultiselect();
}); });
}; };
const handleDownloadFiles = async () => {
await bulkDownload('immich', Array.from($selectedAssets), () => {
assetInteractionStore.clearMultiselect();
});
};
</script> </script>
<svelte:head> <svelte:head>
@ -125,6 +132,11 @@
</p> </p>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
<CircleIconButton
title="Download"
logo={CloudDownloadOutline}
on:click={handleDownloadFiles}
/>
<CircleIconButton title="Add" logo={Plus} on:click={handleShowMenu} /> <CircleIconButton title="Add" logo={Plus} on:click={handleShowMenu} />
<CircleIconButton <CircleIconButton
title="Delete" title="Delete"

View file

@ -0,0 +1,9 @@
<svelte:head>
<title>Opps! Error - Immich</title>
</svelte:head>
<section class="w-screen h-screen flex place-items-center place-content-center">
<div class="p-20 text-4xl dark:text-immich-dark-primary text-immich-primary">
Page not found :/
</div>
</section>

View file

@ -0,0 +1,18 @@
export const prerender = false;
import { error } from '@sveltejs/kit';
import { serverApi } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const { key } = params;
try {
const { data: sharedLink } = await serverApi.shareApi.getMySharedLink({ params: { key } });
return { sharedLink };
} catch (e) {
throw error(404, {
message: 'Invalid shared link'
});
}
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import { AlbumResponseDto } from '../../../api';
import type { PageData } from './$types';
export let data: PageData;
let album: AlbumResponseDto | null = null;
if (data.sharedLink.album) {
album = { ...data.sharedLink.album, assets: data.sharedLink.assets };
}
</script>
<svelte:head>
<title>{data.sharedLink.album?.albumName || 'Public Shared'} - Immich</title>
</svelte:head>
{#if album}
<div class="immich-scrollbar">
<AlbumViewer {album} sharedLink={data.sharedLink} />
</div>
{/if}

View file

@ -0,0 +1,21 @@
export const prerender = false;
import { error } from '@sveltejs/kit';
import { serverApi } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
try {
const { key, assetId } = params;
const { data: asset } = await serverApi.assetApi.getAssetById(assetId, {
params: { key }
});
if (!asset) {
return error(404, 'Asset not found');
}
return { asset, key };
} catch (e) {
console.log('Error', e);
}
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
export let data: PageData;
</script>
{#if data.asset && data.key}
<AssetViewer
asset={data.asset}
publicSharedKey={data.key}
on:navigate-previous={() => null}
on:navigate-next={() => null}
showNavigation={false}
on:close={() => goto(`/share/${data.key}`)}
/>
{/if}

View file

@ -2,6 +2,8 @@
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte'; import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '@api'; import { api } from '@api';
@ -55,7 +57,7 @@
<p class="font-medium">Sharing</p> <p class="font-medium">Sharing</p>
</div> </div>
<div> <div class="flex">
<button <button
on:click={createSharedAlbum} on:click={createSharedAlbum}
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700 dark:hover:bg-immich-dark-primary/25 dark:text-immich-dark-fg" class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700 dark:hover:bg-immich-dark-primary/25 dark:text-immich-dark-fg"
@ -65,6 +67,16 @@
</span> </span>
<p>Create shared album</p> <p>Create shared album</p>
</button> </button>
<button
on:click={() => goto('/sharing/sharedlinks')}
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700 dark:hover:bg-immich-dark-primary/25 dark:text-immich-dark-fg"
>
<span>
<Link size="18" />
</span>
<p>Shared links</p>
</button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
export const prerender = false;
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
try {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
return {
user
};
} catch (e) {
throw redirect(302, '/auth/login');
}
};

Some files were not shown because too many files have changed in this diff Show more