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

feat(server): Add support for client-side hashing (#2072)

* Modify controller DTOs

* Can check duplicates on server side

* Remove deviceassetid and deviceid

* Remove device ids from file uploader

* Add db migration for removed device ids

* Don't sanitize checksum

* Convert asset checksum to string

* Make checksum not optional for asset

* Use enums when rejecting duplicates

* Cleanup

* Return of the device id, but optional

* Don't use deviceId for upload folder

* Use checksum in thumb path

* Only use asset id in thumb path

* Openapi generation

* Put deviceAssetId back in asset response dto

* Add missing checksum in test fixture

* Add another missing checksum in test fixture

* Cleanup asset repository

* Add back previous /exists endpoint

* Require checksum to not be null

* Correctly set deviceId in db

* Remove index

* Fix compilation errors

* Make device id nullabel in asset response dto

* Reduce PR scope

* Revert asset service

* Reorder imports

* Reorder imports

* Reduce PR scope

* Reduce PR scope

* Reduce PR scope

* Reduce PR scope

* Reduce PR scope

* Update openapi

* Reduce PR scope

* refactor: asset bulk upload check

* chore: regenreate open-api

* chore: fix tests

* chore: tests

* update migrations and regenerate api

* Feat: use checksum in web file uploader

* Change to wasm-crypto

* Use crypto api for checksumming in web uploader

* Minor cleanup of file upload

* feat(web): pause and resume jobs

* Make device asset id not nullable again

* Cleanup

* Device id not nullable in response dto

* Update API specs

* Bump api specs

* Remove old TODO comment

* Remove NOT NULL constraint on checksum index

* Fix requested pubspec changes

* Remove unneeded import

* Update server/apps/immich/src/api-v1/asset/asset.service.ts

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* Update server/apps/immich/src/api-v1/asset/asset-repository.ts

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* Remove unneeded check

* Update server/apps/immich/src/api-v1/asset/asset-repository.ts

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* Remove hashing in the web uploader

* Cleanup file uploader

* Remove varchar from asset entity fields

* Return 200 from bulk upload check

* Put device asset id back into asset repository

* Merge migrations

* Revert pubspec lock

* Update openapi specs

* Merge upstream changes

* Fix failing asset service tests

* Fix formatting issue

* Cleanup migrations

* Remove newline from pubspec

* Revert newline

* Checkout main version

* Revert again

* Only return AssetCheck

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
Jonathan Jogenfors 2023-05-24 23:08:21 +02:00 committed by GitHub
parent 49b74e9091
commit 1b54c4f8e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 442 additions and 58 deletions

View File

@ -17,6 +17,10 @@ doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md doc/AlbumResponseDto.md
doc/AllJobStatusResponseDto.md doc/AllJobStatusResponseDto.md
doc/AssetApi.md doc/AssetApi.md
doc/AssetBulkUploadCheckDto.md
doc/AssetBulkUploadCheckItem.md
doc/AssetBulkUploadCheckResponseDto.md
doc/AssetBulkUploadCheckResult.md
doc/AssetCountByTimeBucket.md doc/AssetCountByTimeBucket.md
doc/AssetCountByTimeBucketResponseDto.md doc/AssetCountByTimeBucketResponseDto.md
doc/AssetCountByUserIdResponseDto.md doc/AssetCountByUserIdResponseDto.md
@ -142,6 +146,10 @@ lib/model/api_key_create_dto.dart
lib/model/api_key_create_response_dto.dart lib/model/api_key_create_response_dto.dart
lib/model/api_key_response_dto.dart lib/model/api_key_response_dto.dart
lib/model/api_key_update_dto.dart lib/model/api_key_update_dto.dart
lib/model/asset_bulk_upload_check_dto.dart
lib/model/asset_bulk_upload_check_item.dart
lib/model/asset_bulk_upload_check_response_dto.dart
lib/model/asset_bulk_upload_check_result.dart
lib/model/asset_count_by_time_bucket.dart lib/model/asset_count_by_time_bucket.dart
lib/model/asset_count_by_time_bucket_response_dto.dart lib/model/asset_count_by_time_bucket_response_dto.dart
lib/model/asset_count_by_user_id_response_dto.dart lib/model/asset_count_by_user_id_response_dto.dart
@ -236,6 +244,10 @@ test/api_key_create_response_dto_test.dart
test/api_key_response_dto_test.dart test/api_key_response_dto_test.dart
test/api_key_update_dto_test.dart test/api_key_update_dto_test.dart
test/asset_api_test.dart test/asset_api_test.dart
test/asset_bulk_upload_check_dto_test.dart
test/asset_bulk_upload_check_item_test.dart
test/asset_bulk_upload_check_response_dto_test.dart
test/asset_bulk_upload_check_result_test.dart
test/asset_count_by_time_bucket_response_dto_test.dart test/asset_count_by_time_bucket_response_dto_test.dart
test/asset_count_by_time_bucket_test.dart test/asset_count_by_time_bucket_test.dart
test/asset_count_by_user_id_response_dto_test.dart test/asset_count_by_user_id_response_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -10,13 +10,17 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { In } from 'typeorm/find-options/operator/In'; import { In } from 'typeorm/find-options/operator/In';
import { UpdateAssetDto } from './dto/update-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto';
import { ITagRepository } from '../tag/tag.repository'; import { ITagRepository } from '../tag/tag.repository';
import { IsNull, Not } from 'typeorm'; import { IsNull, Not } from 'typeorm';
import { AssetSearchDto } from './dto/asset-search.dto'; import { AssetSearchDto } from './dto/asset-search.dto';
export interface AssetCheck {
id: string;
checksum: Buffer;
}
export interface IAssetRepository { export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>; get(id: string): Promise<AssetEntity | null>;
create( create(
@ -38,11 +42,8 @@ export interface IAssetRepository {
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>; getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getArchivedAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>; getArchivedAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>; getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets( getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
userId: string,
checkDuplicateAssetDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto>;
countByIdAndUser(assetId: string, userId: string): Promise<number>; countByIdAndUser(assetId: string, userId: string): Promise<number>;
} }
@ -310,41 +311,39 @@ export class AssetRepository implements IAssetRepository {
* @returns Promise<string[]> - Array of assetIds belong to the device * @returns Promise<string[]> - Array of assetIds belong to the device
*/ */
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> { async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
const rows = await this.assetRepository.find({ const items = await this.assetRepository.find({
select: { deviceAssetId: true },
where: { where: {
ownerId, ownerId,
deviceId, deviceId,
isVisible: true, isVisible: true,
}, },
select: ['deviceAssetId'],
}); });
const res: string[] = [];
rows.forEach((v) => res.push(v.deviceAssetId));
return res; return items.map((asset) => asset.deviceAssetId);
} }
/** /**
* Get asset by checksum on the database * Get assets by checksums on the database
* @param ownerId * @param ownerId
* @param checksum * @param checksums
* *
*/ */
getAssetByChecksum(ownerId: string, checksum: Buffer): Promise<AssetEntity> { async getAssetsByChecksums(ownerId: string, checksums: Buffer[]): Promise<AssetCheck[]> {
return this.assetRepository.findOneOrFail({ return this.assetRepository.find({
select: {
id: true,
checksum: true,
},
where: { where: {
ownerId, ownerId,
checksum, checksum: In(checksums),
}, },
relations: ['exifInfo'],
}); });
} }
async getExistingAssets( async getExistingAssets(ownerId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]> {
ownerId: string, const assets = await this.assetRepository.find({
checkDuplicateAssetDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
const existingAssets = await this.assetRepository.find({
select: { deviceAssetId: true }, select: { deviceAssetId: true },
where: { where: {
deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds), deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds),
@ -352,7 +351,7 @@ export class AssetRepository implements IAssetRepository {
ownerId, ownerId,
}, },
}); });
return new CheckExistingAssetsResponseDto(existingAssets.map((a) => a.deviceAssetId)); return assets.map((asset) => asset.deviceAssetId);
} }
async countByIdAndUser(assetId: string, ownerId: string): Promise<number> { async countByIdAndUser(assetId: string, ownerId: string): Promise<number> {

View File

@ -57,6 +57,8 @@ import { AssetSearchDto } from './dto/asset-search.dto';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import FileNotEmptyValidator from '../validation/file-not-empty-validator'; import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto'; import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
import { AssetIdDto } from './dto/asset-id.dto'; import { AssetIdDto } from './dto/asset-id.dto';
import { DeviceIdDto } from './dto/device-id.dto'; import { DeviceIdDto } from './dto/device-id.dto';
@ -332,6 +334,19 @@ export class AssetController {
return await this.assetService.checkExistingAssets(authUser, checkExistingAssetsDto); return await this.assetService.checkExistingAssets(authUser, checkExistingAssetsDto);
} }
/**
* Checks if assets exist by checksums
*/
@Authenticated()
@Post('/bulk-upload-check')
@HttpCode(200)
bulkUploadCheck(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.assetService.bulkUploadCheck(authUser, dto);
}
@Authenticated() @Authenticated()
@Post('/shared-link') @Post('/shared-link')
async createAssetsSharedLink( async createAssetsSharedLink(

View File

@ -17,7 +17,7 @@ export class AssetCore {
owner: { id: authUser.id } as UserEntity, owner: { id: authUser.id } as UserEntity,
mimeType: file.mimeType, mimeType: file.mimeType,
checksum: file.checksum || null, checksum: file.checksum,
originalPath: file.originalPath, originalPath: file.originalPath,
deviceAssetId: dto.deviceAssetId, deviceAssetId: dto.deviceAssetId,

View File

@ -157,7 +157,7 @@ describe('AssetService', () => {
getLocationsByUserId: jest.fn(), getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(), getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(), getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(), getAssetsByChecksums: jest.fn(),
getAssetCountByUserId: jest.fn(), getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(), getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(), getExistingAssets: jest.fn(),
@ -299,7 +299,7 @@ describe('AssetService', () => {
(error as any).constraint = 'UQ_userid_checksum'; (error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error); assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetByChecksum.mockResolvedValue(_getAsset_1()); assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' }); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });

View File

@ -63,6 +63,12 @@ import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { AssetSearchDto } from './dto/asset-search.dto'; import { AssetSearchDto } from './dto/asset-search.dto';
import { AddAssetsDto } from '../album/dto/add-assets.dto'; import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto'; import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import {
AssetUploadAction,
AssetRejectReason,
AssetBulkUploadCheckResponseDto,
} from './response-dto/asset-check-response.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -128,7 +134,8 @@ export class AssetService {
// handle duplicates with a success response // handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') { if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
const duplicate = await this.getAssetByChecksum(authUser.id, file.checksum); const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum);
const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, checksums);
return { id: duplicate.id, duplicate: true }; return { id: duplicate.id, duplicate: true };
} }
@ -463,7 +470,40 @@ export class AssetService {
authUser: AuthUserDto, authUser: AuthUserDto,
checkExistingAssetsDto: CheckExistingAssetsDto, checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> { ): Promise<CheckExistingAssetsResponseDto> {
return this._assetRepository.getExistingAssets(authUser.id, checkExistingAssetsDto); return {
existingIds: await this._assetRepository.getExistingAssets(authUser.id, checkExistingAssetsDto),
};
}
async bulkUploadCheck(authUser: AuthUserDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => Buffer.from(asset.checksum, 'hex'));
const results = await this._assetRepository.getAssetsByChecksums(authUser.id, checksums);
const resultsMap: Record<string, string> = {};
for (const { id, checksum } of results) {
resultsMap[checksum.toString('hex')] = id;
}
return {
results: dto.assets.map(({ id, checksum }) => {
const duplicate = resultsMap[checksum];
if (duplicate) {
return {
id,
assetId: duplicate,
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
};
}
// TODO mime-check
return {
id,
action: AssetUploadAction.ACCEPT,
};
}),
};
} }
async getAssetCountByTimeBucket( async getAssetCountByTimeBucket(
@ -482,10 +522,6 @@ export class AssetService {
return mapAssetCountByTimeBucket(result); return mapAssetCountByTimeBucket(result);
} }
getAssetByChecksum(userId: string, checksum: Buffer) {
return this._assetRepository.getAssetByChecksum(userId, checksum);
}
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> { getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id); return this._assetRepository.getAssetCountByUserId(authUser.id);
} }

View File

@ -0,0 +1,19 @@
import { Type } from 'class-transformer';
import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
export class AssetBulkUploadCheckItem {
@IsString()
@IsNotEmpty()
id!: string;
@IsString()
@IsNotEmpty()
checksum!: string;
}
export class AssetBulkUploadCheckDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetBulkUploadCheckItem)
assets!: AssetBulkUploadCheckItem[];
}

View File

@ -0,0 +1,20 @@
export class AssetBulkUploadCheckResult {
id!: string;
action!: AssetUploadAction;
reason?: AssetRejectReason;
assetId?: string;
}
export class AssetBulkUploadCheckResponseDto {
results!: AssetBulkUploadCheckResult[];
}
export enum AssetUploadAction {
ACCEPT = 'accept',
REJECT = 'reject',
}
export enum AssetRejectReason {
DUPLICATE = 'duplicate',
UNSUPPORTED_FORMAT = 'unsupported-format',
}

View File

@ -1,6 +1,3 @@
export class CheckExistingAssetsResponseDto { export class CheckExistingAssetsResponseDto {
constructor(existingIds: string[]) { existingIds!: string[];
this.existingIds = existingIds;
}
existingIds: string[];
} }

View File

@ -3251,6 +3251,49 @@
] ]
} }
}, },
"/asset/bulk-upload-check": {
"post": {
"operationId": "bulkUploadCheck",
"description": "Checks if assets exist by checksums",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetBulkUploadCheckDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetBulkUploadCheckResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/shared-link": { "/asset/shared-link": {
"post": { "post": {
"operationId": "createAssetsSharedLink", "operationId": "createAssetsSharedLink",
@ -6046,6 +6089,78 @@
"existingIds" "existingIds"
] ]
}, },
"AssetBulkUploadCheckItem": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"checksum": {
"type": "string"
}
},
"required": [
"id",
"checksum"
]
},
"AssetBulkUploadCheckDto": {
"type": "object",
"properties": {
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetBulkUploadCheckItem"
}
}
},
"required": [
"assets"
]
},
"AssetBulkUploadCheckResult": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"accept",
"reject"
]
},
"reason": {
"type": "string",
"enum": [
"duplicate",
"unsupported-format"
]
},
"assetId": {
"type": "string"
}
},
"required": [
"id",
"action"
]
},
"AssetBulkUploadCheckResponseDto": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetBulkUploadCheckResult"
}
}
},
"required": [
"results"
]
},
"CreateAssetsShareLinkDto": { "CreateAssetsShareLinkDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -147,6 +147,7 @@ export const assetEntityStub = {
deviceId: 'device-id', deviceId: 'device-id',
originalPath: 'upload/upload/path.ext', originalPath: 'upload/upload/path.ext',
resizePath: null, resizePath: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: null,
encodedVideoPath: null, encodedVideoPath: null,
@ -173,6 +174,7 @@ export const assetEntityStub = {
deviceId: 'device-id', deviceId: 'device-id',
originalPath: '/original/path.ext', originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext', resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: null,
encodedVideoPath: null, encodedVideoPath: null,
@ -201,6 +203,7 @@ export const assetEntityStub = {
deviceId: 'device-id', deviceId: 'device-id',
originalPath: '/original/path.ext', originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext', resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.VIDEO, type: AssetType.VIDEO,
webpPath: null, webpPath: null,
encodedVideoPath: null, encodedVideoPath: null,
@ -246,6 +249,7 @@ export const assetEntityStub = {
owner: userEntityStub.user1, owner: userEntityStub.user1,
ownerId: 'user-id', ownerId: 'user-id',
deviceId: 'device-id', deviceId: 'device-id',
checksum: Buffer.from('file hash', 'utf8'),
originalPath: '/original/path.ext', originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext', resizePath: '/uploads/user-id/thumbs/path.ext',
type: AssetType.IMAGE, type: AssetType.IMAGE,
@ -663,6 +667,7 @@ export const sharedLinkStub = {
type: AssetType.VIDEO, type: AssetType.VIDEO,
originalPath: 'fake_path/jpeg', originalPath: 'fake_path/jpeg',
resizePath: '', resizePath: '',
checksum: Buffer.from('file hash', 'utf8'),
fileModifiedAt: today.toISOString(), fileModifiedAt: today.toISOString(),
fileCreatedAt: today.toISOString(), fileCreatedAt: today.toISOString(),
createdAt: today.toISOString(), createdAt: today.toISOString(),

View File

@ -75,9 +75,9 @@ export class AssetEntity {
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
mimeType!: string | null; mimeType!: string | null;
@Column({ type: 'bytea', nullable: true, select: false }) @Column({ type: 'bytea' })
@Index({ where: `'checksum' IS NOT NULL` }) // avoid null index @Index()
checksum?: Buffer | null; // sha1 checksum checksum!: Buffer; // sha1 checksum
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
duration!: string | null; duration!: string | null;

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RequireChecksumNotNull1684328185099 implements MigrationInterface {
name = 'removeNotNullFromChecksumIndex1684328185099';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "checksum" SET NOT NULL`);
await queryRunner.query(`CREATE INDEX "IDX_8d3efe36c0755849395e6ea866" ON "assets" ("checksum") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_8d3efe36c0755849395e6ea866"`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "checksum" DROP NOT NULL`);
await queryRunner.query(
`CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE ('checksum' IS NOT NULL)`,
);
}
}

View File

@ -346,6 +346,96 @@ export interface AllJobStatusResponseDto {
*/ */
'recognize-faces-queue': JobStatusDto; 'recognize-faces-queue': JobStatusDto;
} }
/**
*
* @export
* @interface AssetBulkUploadCheckDto
*/
export interface AssetBulkUploadCheckDto {
/**
*
* @type {Array<AssetBulkUploadCheckItem>}
* @memberof AssetBulkUploadCheckDto
*/
'assets': Array<AssetBulkUploadCheckItem>;
}
/**
*
* @export
* @interface AssetBulkUploadCheckItem
*/
export interface AssetBulkUploadCheckItem {
/**
*
* @type {string}
* @memberof AssetBulkUploadCheckItem
*/
'id': string;
/**
*
* @type {string}
* @memberof AssetBulkUploadCheckItem
*/
'checksum': string;
}
/**
*
* @export
* @interface AssetBulkUploadCheckResponseDto
*/
export interface AssetBulkUploadCheckResponseDto {
/**
*
* @type {Array<AssetBulkUploadCheckResult>}
* @memberof AssetBulkUploadCheckResponseDto
*/
'results': Array<AssetBulkUploadCheckResult>;
}
/**
*
* @export
* @interface AssetBulkUploadCheckResult
*/
export interface AssetBulkUploadCheckResult {
/**
*
* @type {string}
* @memberof AssetBulkUploadCheckResult
*/
'id': string;
/**
*
* @type {string}
* @memberof AssetBulkUploadCheckResult
*/
'action': AssetBulkUploadCheckResultActionEnum;
/**
*
* @type {string}
* @memberof AssetBulkUploadCheckResult
*/
'reason'?: AssetBulkUploadCheckResultReasonEnum;
/**
*
* @type {string}
* @memberof AssetBulkUploadCheckResult
*/
'assetId'?: string;
}
export const AssetBulkUploadCheckResultActionEnum = {
Accept: 'accept',
Reject: 'reject'
} as const;
export type AssetBulkUploadCheckResultActionEnum = typeof AssetBulkUploadCheckResultActionEnum[keyof typeof AssetBulkUploadCheckResultActionEnum];
export const AssetBulkUploadCheckResultReasonEnum = {
Duplicate: 'duplicate',
UnsupportedFormat: 'unsupported-format'
} as const;
export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum];
/** /**
* *
* @export * @export
@ -4120,6 +4210,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
* Checks if assets exist by checksums
* @param {AssetBulkUploadCheckDto} assetBulkUploadCheckDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkUploadCheck: async (assetBulkUploadCheckDto: AssetBulkUploadCheckDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetBulkUploadCheckDto' is not null or undefined
assertParamExists('bulkUploadCheck', 'assetBulkUploadCheckDto', assetBulkUploadCheckDto)
const localVarPath = `/asset/bulk-upload-check`;
// 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 cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// 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(assetBulkUploadCheckDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* Check duplicated asset before uploading - for Web upload used * Check duplicated asset before uploading - for Web upload used
* @param {CheckDuplicateAssetDto} checkDuplicateAssetDto * @param {CheckDuplicateAssetDto} checkDuplicateAssetDto
@ -5312,6 +5446,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToSharedLink(addAssetsDto, key, options); const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToSharedLink(addAssetsDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
* Checks if assets exist by checksums
* @param {AssetBulkUploadCheckDto} assetBulkUploadCheckDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async bulkUploadCheck(assetBulkUploadCheckDto: AssetBulkUploadCheckDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetBulkUploadCheckResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.bulkUploadCheck(assetBulkUploadCheckDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* Check duplicated asset before uploading - for Web upload used * Check duplicated asset before uploading - for Web upload used
* @param {CheckDuplicateAssetDto} checkDuplicateAssetDto * @param {CheckDuplicateAssetDto} checkDuplicateAssetDto
@ -5595,6 +5739,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
addAssetsToSharedLink(addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise<SharedLinkResponseDto> { addAssetsToSharedLink(addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.addAssetsToSharedLink(addAssetsDto, key, options).then((request) => request(axios, basePath)); return localVarFp.addAssetsToSharedLink(addAssetsDto, key, options).then((request) => request(axios, basePath));
}, },
/**
* Checks if assets exist by checksums
* @param {AssetBulkUploadCheckDto} assetBulkUploadCheckDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkUploadCheck(assetBulkUploadCheckDto: AssetBulkUploadCheckDto, options?: any): AxiosPromise<AssetBulkUploadCheckResponseDto> {
return localVarFp.bulkUploadCheck(assetBulkUploadCheckDto, options).then((request) => request(axios, basePath));
},
/** /**
* Check duplicated asset before uploading - for Web upload used * Check duplicated asset before uploading - for Web upload used
* @param {CheckDuplicateAssetDto} checkDuplicateAssetDto * @param {CheckDuplicateAssetDto} checkDuplicateAssetDto
@ -5856,6 +6009,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).addAssetsToSharedLink(addAssetsDto, key, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).addAssetsToSharedLink(addAssetsDto, key, options).then((request) => request(this.axios, this.basePath));
} }
/**
* Checks if assets exist by checksums
* @param {AssetBulkUploadCheckDto} assetBulkUploadCheckDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public bulkUploadCheck(assetBulkUploadCheckDto: AssetBulkUploadCheckDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).bulkUploadCheck(assetBulkUploadCheckDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* Check duplicated asset before uploading - for Web upload used * Check duplicated asset before uploading - for Web upload used
* @param {CheckDuplicateAssetDto} checkDuplicateAssetDto * @param {CheckDuplicateAssetDto} checkDuplicateAssetDto

View File

@ -4,7 +4,7 @@ import {
} from './../components/shared-components/notification/notification'; } from './../components/shared-components/notification/notification';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import type { UploadAsset } from '../models/upload-asset'; import type { UploadAsset } from '../models/upload-asset';
import { api, AssetFileUploadResponseDto } from '@api'; import { AssetFileUploadResponseDto } from '@api';
import { addAssetsToAlbum, getFileMimeType, getFilenameExtension } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, getFileMimeType, getFilenameExtension } from '$lib/utils/asset-utils';
import { mergeMap, filter, firstValueFrom, from, of, combineLatestAll } from 'rxjs'; import { mergeMap, filter, firstValueFrom, from, of, combineLatestAll } from 'rxjs';
import axios from 'axios'; import axios from 'axios';
@ -73,7 +73,7 @@ async function fileUploader(
const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified; const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified;
try { try {
// Create and add Unique ID of asset on the device // Create and add pseudo-unique ID of asset on the device
formData.append('deviceAssetId', deviceAssetId); formData.append('deviceAssetId', deviceAssetId);
// Get device id - for web -> use WEB // Get device id - for web -> use WEB
@ -102,23 +102,6 @@ async function fileUploader(
// failed uploads. // failed uploads.
formData.append('assetData', new File([asset], asset.name, { type: mimeType })); formData.append('assetData', new File([asset], asset.name, { type: mimeType }));
// Check if asset upload on server before performing upload
const { data, status } = await api.assetApi.checkDuplicateAsset(
{
deviceAssetId: String(deviceAssetId),
deviceId: 'WEB'
},
sharedKey
);
if (status === 200 && data.isExist && data.id) {
if (albumId) {
await addAssetsToAlbum(albumId, [data.id], sharedKey);
}
return data.id;
}
const newUploadAsset: UploadAsset = { const newUploadAsset: UploadAsset = {
id: deviceAssetId, id: deviceAssetId,
file: asset, file: asset,