1
0
mirror of https://github.com/immich-app/immich.git synced 2025-02-02 18:25:38 +02:00

feat(server): asset entity audit (#3824)

* feat(server): audit log

* feedback

* Insert to database

* migration

* test

* controller/repository/service

* test

* module

* feat(server): implement audit endpoint

* directly return changed assets

* add daily cleanup of audit table

* fix tests

* review feedback

* ci

* refactor(server): audit implementation

* chore: open api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Fynn Petersen-Frey 2023-08-24 21:28:50 +02:00 committed by GitHub
parent d6887117ac
commit cf9e04c8ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1381 additions and 36 deletions

View File

@ -752,6 +752,25 @@ export const AudioCodec = {
export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec];
/**
*
* @export
* @interface AuditDeletesResponseDto
*/
export interface AuditDeletesResponseDto {
/**
*
* @type {Array<string>}
* @memberof AuditDeletesResponseDto
*/
'ids': Array<string>;
/**
*
* @type {boolean}
* @memberof AuditDeletesResponseDto
*/
'needsFullSync': boolean;
}
/**
*
* @export
@ -1243,6 +1262,20 @@ export interface DownloadResponseDto {
*/
'totalSize': number;
}
/**
*
* @export
* @enum {string}
*/
export const EntityType = {
Asset: 'ASSET',
Album: 'ALBUM'
} as const;
export type EntityType = typeof EntityType[keyof typeof EntityType];
/**
*
* @export
@ -5120,11 +5153,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {boolean} [isArchived]
* @param {boolean} [withoutThumbs] Include assets without thumbnails
* @param {number} [skip]
* @param {string} [updatedAfter]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -5166,6 +5200,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['skip'] = skip;
}
if (updatedAfter !== undefined) {
localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ?
(updatedAfter as any).toISOString() :
updatedAfter;
}
if (ifNoneMatch != null) {
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
}
@ -6274,12 +6314,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {boolean} [isArchived]
* @param {boolean} [withoutThumbs] Include assets without thumbnails
* @param {number} [skip]
* @param {string} [updatedAfter]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options);
async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, updatedAfter, ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -6576,7 +6617,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @throws {RequiredError}
*/
getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath));
return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath));
},
/**
* Get a single asset\'s information
@ -6895,6 +6936,13 @@ export interface AssetApiGetAllAssetsRequest {
*/
readonly skip?: number
/**
*
* @type {string}
* @memberof AssetApiGetAllAssets
*/
readonly updatedAfter?: string
/**
* ETag of data already cached on the client
* @type {string}
@ -7460,7 +7508,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi
*/
public getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -7671,6 +7719,163 @@ export class AssetApi extends BaseAPI {
}
/**
* AuditApi - axios parameter creator
* @export
*/
export const AuditApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {EntityType} entityType
* @param {string} after
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuditDeletes: async (entityType: EntityType, after: string, userId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'entityType' is not null or undefined
assertParamExists('getAuditDeletes', 'entityType', entityType)
// verify required parameter 'after' is not null or undefined
assertParamExists('getAuditDeletes', 'after', after)
const localVarPath = `/audit/deletes`;
// 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;
// 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)
if (entityType !== undefined) {
localVarQueryParameter['entityType'] = entityType;
}
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
if (after !== undefined) {
localVarQueryParameter['after'] = (after as any instanceof Date) ?
(after as any).toISOString() :
after;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* AuditApi - functional programming interface
* @export
*/
export const AuditApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = AuditApiAxiosParamCreator(configuration)
return {
/**
*
* @param {EntityType} entityType
* @param {string} after
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAuditDeletes(entityType: EntityType, after: string, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AuditDeletesResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditDeletes(entityType, after, userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* AuditApi - factory interface
* @export
*/
export const AuditApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = AuditApiFp(configuration)
return {
/**
*
* @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig): AxiosPromise<AuditDeletesResponseDto> {
return localVarFp.getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for getAuditDeletes operation in AuditApi.
* @export
* @interface AuditApiGetAuditDeletesRequest
*/
export interface AuditApiGetAuditDeletesRequest {
/**
*
* @type {EntityType}
* @memberof AuditApiGetAuditDeletes
*/
readonly entityType: EntityType
/**
*
* @type {string}
* @memberof AuditApiGetAuditDeletes
*/
readonly after: string
/**
*
* @type {string}
* @memberof AuditApiGetAuditDeletes
*/
readonly userId?: string
}
/**
* AuditApi - object-oriented interface
* @export
* @class AuditApi
* @extends {BaseAPI}
*/
export class AuditApi extends BaseAPI {
/**
*
* @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuditApi
*/
public getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig) {
return AuditApiFp(this.configuration).getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* AuthenticationApi - axios parameter creator
* @export

View File

@ -29,6 +29,8 @@ doc/AssetResponseDto.md
doc/AssetStatsResponseDto.md
doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuditApi.md
doc/AuditDeletesResponseDto.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/BulkIdResponseDto.md
@ -50,6 +52,7 @@ doc/DeleteAssetStatus.md
doc/DownloadArchiveInfo.md
doc/DownloadInfoDto.md
doc/DownloadResponseDto.md
doc/EntityType.md
doc/ExifResponseDto.md
doc/ImportAssetDto.md
doc/JobApi.md
@ -134,6 +137,7 @@ lib/api.dart
lib/api/album_api.dart
lib/api/api_key_api.dart
lib/api/asset_api.dart
lib/api/audit_api.dart
lib/api/authentication_api.dart
lib/api/job_api.dart
lib/api/o_auth_api.dart
@ -176,6 +180,7 @@ lib/model/asset_response_dto.dart
lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/audit_deletes_response_dto.dart
lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart
lib/model/bulk_ids_dto.dart
@ -196,6 +201,7 @@ lib/model/delete_asset_status.dart
lib/model/download_archive_info.dart
lib/model/download_info_dto.dart
lib/model/download_response_dto.dart
lib/model/entity_type.dart
lib/model/exif_response_dto.dart
lib/model/import_asset_dto.dart
lib/model/job_command.dart
@ -292,6 +298,8 @@ test/asset_response_dto_test.dart
test/asset_stats_response_dto_test.dart
test/asset_type_enum_test.dart
test/audio_codec_test.dart
test/audit_api_test.dart
test/audit_deletes_response_dto_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart
@ -313,6 +321,7 @@ test/delete_asset_status_test.dart
test/download_archive_info_test.dart
test/download_info_dto_test.dart
test/download_response_dto_test.dart
test/entity_type_test.dart
test/exif_response_dto_test.dart
test/import_asset_dto_test.dart
test/job_api_test.dart

View File

@ -113,6 +113,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |
*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset |
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
@ -204,6 +205,7 @@ Class | Method | HTTP request | Description
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
- [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md)
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
@ -224,6 +226,7 @@ Class | Method | HTTP request | Description
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [EntityType](doc//EntityType.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
- [ImportAssetDto](doc//ImportAssetDto.md)
- [JobCommand](doc//JobCommand.md)

View File

@ -380,7 +380,7 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllAssets**
> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch)
> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, updatedAfter, ifNoneMatch)
@ -410,10 +410,11 @@ final isFavorite = true; // bool |
final isArchived = true; // bool |
final withoutThumbs = true; // bool | Include assets without thumbnails
final skip = 8.14; // num |
final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
try {
final result = api_instance.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch);
final result = api_instance.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, updatedAfter, ifNoneMatch);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAllAssets: $e\n');
@ -429,6 +430,7 @@ Name | Type | Description | Notes
**isArchived** | **bool**| | [optional]
**withoutThumbs** | **bool**| Include assets without thumbnails | [optional]
**skip** | **num**| | [optional]
**updatedAfter** | **DateTime**| | [optional]
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
### Return type

73
mobile/openapi/doc/AuditApi.md generated Normal file
View File

@ -0,0 +1,73 @@
# openapi.api.AuditApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getAuditDeletes**](AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
# **getAuditDeletes**
> AuditDeletesResponseDto getAuditDeletes(entityType, after, userId)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AuditApi();
final entityType = ; // EntityType |
final after = 2013-10-20T19:20:30+01:00; // DateTime |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getAuditDeletes(entityType, after, userId);
print(result);
} catch (e) {
print('Exception when calling AuditApi->getAuditDeletes: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**entityType** | [**EntityType**](.md)| |
**after** | **DateTime**| |
**userId** | **String**| | [optional]
### Return type
[**AuditDeletesResponseDto**](AuditDeletesResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@ -0,0 +1,16 @@
# openapi.model.AuditDeletesResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ids** | **List<String>** | | [default to const []]
**needsFullSync** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

14
mobile/openapi/doc/EntityType.md generated Normal file
View File

@ -0,0 +1,14 @@
# openapi.model.EntityType
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -31,6 +31,7 @@ part 'auth/http_bearer_auth.dart';
part 'api/api_key_api.dart';
part 'api/album_api.dart';
part 'api/asset_api.dart';
part 'api/audit_api.dart';
part 'api/authentication_api.dart';
part 'api/job_api.dart';
part 'api/o_auth_api.dart';
@ -66,6 +67,7 @@ part 'model/asset_response_dto.dart';
part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart';
part 'model/audit_deletes_response_dto.dart';
part 'model/auth_device_response_dto.dart';
part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart';
@ -86,6 +88,7 @@ part 'model/delete_asset_status.dart';
part 'model/download_archive_info.dart';
part 'model/download_info_dto.dart';
part 'model/download_response_dto.dart';
part 'model/entity_type.dart';
part 'model/exif_response_dto.dart';
part 'model/import_asset_dto.dart';
part 'model/job_command.dart';

View File

@ -358,9 +358,11 @@ class AssetApi {
///
/// * [num] skip:
///
/// * [DateTime] updatedAfter:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<Response> getAllAssetsWithHttpInfo({ String? userId, bool? isFavorite, bool? isArchived, bool? withoutThumbs, num? skip, String? ifNoneMatch, }) async {
Future<Response> getAllAssetsWithHttpInfo({ String? userId, bool? isFavorite, bool? isArchived, bool? withoutThumbs, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async {
// ignore: prefer_const_declarations
final path = r'/asset';
@ -386,6 +388,9 @@ class AssetApi {
if (skip != null) {
queryParams.addAll(_queryParams('', 'skip', skip));
}
if (updatedAfter != null) {
queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter));
}
if (ifNoneMatch != null) {
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
@ -420,10 +425,12 @@ class AssetApi {
///
/// * [num] skip:
///
/// * [DateTime] updatedAfter:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<List<AssetResponseDto>?> getAllAssets({ String? userId, bool? isFavorite, bool? isArchived, bool? withoutThumbs, num? skip, String? ifNoneMatch, }) async {
final response = await getAllAssetsWithHttpInfo( userId: userId, isFavorite: isFavorite, isArchived: isArchived, withoutThumbs: withoutThumbs, skip: skip, ifNoneMatch: ifNoneMatch, );
Future<List<AssetResponseDto>?> getAllAssets({ String? userId, bool? isFavorite, bool? isArchived, bool? withoutThumbs, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async {
final response = await getAllAssetsWithHttpInfo( userId: userId, isFavorite: isFavorite, isArchived: isArchived, withoutThumbs: withoutThumbs, skip: skip, updatedAfter: updatedAfter, ifNoneMatch: ifNoneMatch, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

79
mobile/openapi/lib/api/audit_api.dart generated Normal file
View File

@ -0,0 +1,79 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuditApi {
AuditApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /audit/deletes' operation and returns the [Response].
/// Parameters:
///
/// * [EntityType] entityType (required):
///
/// * [DateTime] after (required):
///
/// * [String] userId:
Future<Response> getAuditDeletesWithHttpInfo(EntityType entityType, DateTime after, { String? userId, }) async {
// ignore: prefer_const_declarations
final path = r'/audit/deletes';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'entityType', entityType));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
queryParams.addAll(_queryParams('', 'after', after));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [EntityType] entityType (required):
///
/// * [DateTime] after (required):
///
/// * [String] userId:
Future<AuditDeletesResponseDto?> getAuditDeletes(EntityType entityType, DateTime after, { String? userId, }) async {
final response = await getAuditDeletesWithHttpInfo(entityType, after, userId: userId, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuditDeletesResponseDto',) as AuditDeletesResponseDto;
}
return null;
}
}

View File

@ -227,6 +227,8 @@ class ApiClient {
return AssetTypeEnumTypeTransformer().decode(value);
case 'AudioCodec':
return AudioCodecTypeTransformer().decode(value);
case 'AuditDeletesResponseDto':
return AuditDeletesResponseDto.fromJson(value);
case 'AuthDeviceResponseDto':
return AuthDeviceResponseDto.fromJson(value);
case 'BulkIdResponseDto':
@ -267,6 +269,8 @@ class ApiClient {
return DownloadInfoDto.fromJson(value);
case 'DownloadResponseDto':
return DownloadResponseDto.fromJson(value);
case 'EntityType':
return EntityTypeTypeTransformer().decode(value);
case 'ExifResponseDto':
return ExifResponseDto.fromJson(value);
case 'ImportAssetDto':

View File

@ -67,6 +67,9 @@ String parameterToString(dynamic value) {
if (value is DeleteAssetStatus) {
return DeleteAssetStatusTypeTransformer().encode(value).toString();
}
if (value is EntityType) {
return EntityTypeTypeTransformer().encode(value).toString();
}
if (value is JobCommand) {
return JobCommandTypeTransformer().encode(value).toString();
}

View File

@ -0,0 +1,108 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuditDeletesResponseDto {
/// Returns a new [AuditDeletesResponseDto] instance.
AuditDeletesResponseDto({
this.ids = const [],
required this.needsFullSync,
});
List<String> ids;
bool needsFullSync;
@override
bool operator ==(Object other) => identical(this, other) || other is AuditDeletesResponseDto &&
other.ids == ids &&
other.needsFullSync == needsFullSync;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(needsFullSync.hashCode);
@override
String toString() => 'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
json[r'needsFullSync'] = this.needsFullSync;
return json;
}
/// Returns a new [AuditDeletesResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AuditDeletesResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AuditDeletesResponseDto(
ids: json[r'ids'] is List
? (json[r'ids'] as List).cast<String>()
: const [],
needsFullSync: mapValueOfType<bool>(json, r'needsFullSync')!,
);
}
return null;
}
static List<AuditDeletesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AuditDeletesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AuditDeletesResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AuditDeletesResponseDto> mapFromJson(dynamic json) {
final map = <String, AuditDeletesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AuditDeletesResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AuditDeletesResponseDto-objects as value to a dart map
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AuditDeletesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AuditDeletesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ids',
'needsFullSync',
};
}

View File

@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EntityType {
/// Instantiate a new enum with the provided [value].
const EntityType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const ASSET = EntityType._(r'ASSET');
static const ALBUM = EntityType._(r'ALBUM');
/// List of all possible values in this [enum][EntityType].
static const values = <EntityType>[
ASSET,
ALBUM,
];
static EntityType? fromJson(dynamic value) => EntityTypeTypeTransformer().decode(value);
static List<EntityType>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <EntityType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EntityType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [EntityType] to String,
/// and [decode] dynamic data back to [EntityType].
class EntityTypeTypeTransformer {
factory EntityTypeTypeTransformer() => _instance ??= const EntityTypeTypeTransformer._();
const EntityTypeTypeTransformer._();
String encode(EntityType data) => data.value;
/// Decodes a [dynamic value][data] to a EntityType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
EntityType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'ASSET': return EntityType.ASSET;
case r'ALBUM': return EntityType.ALBUM;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [EntityTypeTypeTransformer] instance.
static EntityTypeTypeTransformer? _instance;
}

View File

@ -55,7 +55,7 @@ void main() {
// Get all AssetEntity belong to the user
//
//Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, bool withoutThumbs, num skip, String ifNoneMatch }) async
//Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, bool withoutThumbs, num skip, DateTime updatedAfter, String ifNoneMatch }) async
test('test getAllAssets', () async {
// TODO
});

26
mobile/openapi/test/audit_api_test.dart generated Normal file
View File

@ -0,0 +1,26 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
/// tests for AuditApi
void main() {
// final instance = AuditApi();
group('tests for AuditApi', () {
//Future<AuditDeletesResponseDto> getAuditDeletes(EntityType entityType, DateTime after, { String userId }) async
test('test getAuditDeletes', () async {
// TODO
});
});
}

View File

@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AuditDeletesResponseDto
void main() {
// final instance = AuditDeletesResponseDto();
group('test AuditDeletesResponseDto', () {
// List<String> ids (default value: const [])
test('to test the property `ids`', () async {
// TODO
});
// bool needsFullSync
test('to test the property `needsFullSync`', () async {
// TODO
});
});
}

View File

@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for EntityType
void main() {
group('test EntityType', () {
});
}

View File

@ -769,6 +769,15 @@
"type": "number"
}
},
{
"name": "updatedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "if-none-match",
"in": "header",
@ -2071,6 +2080,65 @@
]
}
},
"/audit/deletes": {
"get": {
"operationId": "getAuditDeletes",
"parameters": [
{
"name": "entityType",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/EntityType"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "after",
"required": true,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuditDeletesResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Audit"
]
}
},
"/auth/admin-sign-up": {
"post": {
"operationId": "adminSignUp",
@ -5239,6 +5307,24 @@
],
"type": "string"
},
"AuditDeletesResponseDto": {
"properties": {
"ids": {
"items": {
"type": "string"
},
"type": "array"
},
"needsFullSync": {
"type": "boolean"
}
},
"required": [
"needsFullSync",
"ids"
],
"type": "object"
},
"AuthDeviceResponseDto": {
"properties": {
"createdAt": {
@ -5701,6 +5787,13 @@
],
"type": "object"
},
"EntityType": {
"enum": [
"ASSET",
"ALBUM"
],
"type": "string"
},
"ExifResponseDto": {
"properties": {
"city": {

View File

@ -13,7 +13,7 @@ import {
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto';
import { IJobRepository, JobName } from '../index';
import { IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService, UploadFieldName } from './asset.service';

View File

@ -16,18 +16,23 @@ import {
AssetIdsDto,
AssetJobName,
AssetJobsDto,
AssetStatsDto,
DownloadArchiveInfo,
DownloadInfoDto,
DownloadResponseDto,
MapMarkerDto,
mapStats,
MemoryLaneDto,
TimeBucketAssetDto,
TimeBucketDto,
} from './dto';
import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto';
import { MapMarkerDto } from './dto/map-marker.dto';
import { AssetResponseDto, mapAsset, MapMarkerResponseDto } from './response-dto';
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
import { TimeBucketResponseDto } from './response-dto/time-bucket-response.dto';
import {
AssetResponseDto,
mapAsset,
MapMarkerResponseDto,
MemoryLaneResponseDto,
TimeBucketResponseDto,
} from './response-dto';
export enum UploadFieldName {
ASSET_DATA = 'assetData',

View File

@ -84,3 +84,8 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
checksum: entity.checksum.toString('base64'),
};
}
export class MemoryLaneResponseDto {
title!: string;
assets!: AssetResponseDto[];
}

View File

@ -1,6 +0,0 @@
import { AssetResponseDto } from './asset-response.dto';
export class MemoryLaneResponseDto {
title!: string;
assets!: AssetResponseDto[];
}

View File

@ -0,0 +1,61 @@
import { DatabaseAction, EntityType } from '@app/infra/entities';
import { auditStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, newAuditRepositoryMock } from '@test';
import { IAuditRepository } from './audit.repository';
import { AuditService } from './audit.service';
describe(AuditService.name, () => {
let sut: AuditService;
let accessMock: IAccessRepositoryMock;
let auditMock: jest.Mocked<IAuditRepository>;
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
auditMock = newAuditRepositoryMock();
sut = new AuditService(accessMock, auditMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('handleCleanup', () => {
it('should delete old audit entries', async () => {
await expect(sut.handleCleanup()).resolves.toBe(true);
expect(auditMock.removeBefore).toBeCalledWith(expect.any(Date));
});
});
describe('getDeletes', () => {
it('should require full sync if the request is older than 100 days', async () => {
auditMock.getAfter.mockResolvedValue([]);
const date = new Date(2022, 0, 1);
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
needsFullSync: true,
ids: [],
});
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
action: DatabaseAction.DELETE,
ownerId: authStub.admin.id,
entityType: EntityType.ASSET,
});
});
it('should get any new or updated assets and deleted ids', async () => {
auditMock.getAfter.mockResolvedValue([auditStub.delete]);
const date = new Date();
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
needsFullSync: false,
ids: ['asset-deleted'],
});
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
action: DatabaseAction.DELETE,
ownerId: authStub.admin.id,
entityType: EntityType.ASSET,
});
});
});
});

View File

@ -0,0 +1,24 @@
import { EntityType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsEnum, IsOptional, IsUUID } from 'class-validator';
export class AuditDeletesDto {
@IsDate()
@Type(() => Date)
after!: Date;
@ApiProperty({ enum: EntityType, enumName: 'EntityType' })
@IsEnum(EntityType)
entityType!: EntityType;
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
}
export class AuditDeletesResponseDto {
needsFullSync!: boolean;
ids!: string[];
}

View File

@ -0,0 +1,14 @@
import { AuditEntity, DatabaseAction, EntityType } from '@app/infra/entities';
export const IAuditRepository = 'IAuditRepository';
export interface AuditSearch {
action?: DatabaseAction;
entityType?: EntityType;
ownerId?: string;
}
export interface IAuditRepository {
getAfter(since: Date, options: AuditSearch): Promise<AuditEntity[]>;
removeBefore(before: Date): Promise<void>;
}

View File

@ -0,0 +1,43 @@
import { DatabaseAction } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { AuditDeletesDto, AuditDeletesResponseDto } from './audit.dto';
import { IAuditRepository } from './audit.repository';
@Injectable()
export class AuditService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAuditRepository) private repository: IAuditRepository,
) {
this.access = new AccessCore(accessRepository);
}
async handleCleanup(): Promise<boolean> {
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return true;
}
async getDeletes(authUser: AuthUserDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
const audits = await this.repository.getAfter(dto.after, {
ownerId: userId,
entityType: dto.entityType,
action: DatabaseAction.DELETE,
});
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.after));
return {
needsFullSync: duration > AUDIT_LOG_MAX_DURATION,
ids: audits.map(({ entityId }) => entityId),
};
}
}

View File

@ -0,0 +1,3 @@
export * from './audit.dto';
export * from './audit.repository';
export * from './audit.service';

View File

@ -1,8 +1,11 @@
import { AssetType } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { Duration } from 'luxon';
import { extname } from 'node:path';
import pkg from 'src/../../package.json';
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
const [major, minor, patch] = pkg.version.split('.');
export interface IServerVersion {

View File

@ -2,6 +2,7 @@ import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, P
import { AlbumService } from './album';
import { APIKeyService } from './api-key';
import { AssetService } from './asset';
import { AuditService } from './audit';
import { AuthService } from './auth';
import { FacialRecognitionService } from './facial-recognition';
import { JobService } from './job';
@ -23,6 +24,7 @@ const providers: Provider[] = [
AlbumService,
APIKeyService,
AssetService,
AuditService,
AuthService,
FacialRecognitionService,
JobService,

View File

@ -2,6 +2,7 @@ export * from './access';
export * from './album';
export * from './api-key';
export * from './asset';
export * from './audit';
export * from './auth';
export * from './communication';
export * from './crypto';

View File

@ -55,6 +55,7 @@ export enum JobName {
// cleanup
DELETE_FILES = 'delete-files',
CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs',
// search
SEARCH_INDEX_ASSETS = 'search-index-assets',
@ -84,6 +85,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
[JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
// conversion

View File

@ -68,6 +68,9 @@ export type JobItem =
// Filesystem
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
// Audit log cleanup
| { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob }
// Asset Deletion
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }

View File

@ -51,6 +51,7 @@ describe(JobService.name, () => {
[{ name: JobName.USER_DELETE_CHECK }],
[{ name: JobName.PERSON_CLEANUP }],
[{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }],
[{ name: JobName.CLEAN_OLD_AUDIT_LOGS }],
]);
});
});

View File

@ -136,6 +136,7 @@ export class JobService {
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
await this.jobRepository.queue({ name: JobName.CLEAN_OLD_AUDIT_LOGS });
}
/**

View File

@ -1,7 +1,7 @@
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not } from 'typeorm';
import { IsNull, MoreThan, Not } from 'typeorm';
import { In } from 'typeorm/find-options/operator/In';
import { Repository } from 'typeorm/repository/Repository';
import { AssetSearchDto } from './dto/asset-search.dto';
@ -131,6 +131,7 @@ export class AssetRepository implements IAssetRepository {
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
updatedAt: dto.updatedAfter ? MoreThan(dto.updatedAfter) : undefined,
},
relations: {
exifInfo: true,

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsDate, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator';
export class AssetSearchDto {
@IsOptional()
@ -32,4 +32,9 @@ export class AssetSearchDto {
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
@IsOptional()
@IsDate()
@Type(() => Date)
updatedAfter?: Date;
}

View File

@ -16,6 +16,7 @@ import {
APIKeyController,
AppController,
AssetController,
AuditController,
AuthController,
JobController,
OAuthController,
@ -42,6 +43,7 @@ import {
AppController,
AlbumController,
APIKeyController,
AuditController,
AuthController,
JobController,
OAuthController,

View File

@ -9,14 +9,14 @@ import {
AuthUserDto,
DownloadInfoDto,
DownloadResponseDto,
MapMarkerDto,
MapMarkerResponseDto,
MemoryLaneDto,
MemoryLaneResponseDto,
TimeBucketAssetDto,
TimeBucketDto,
TimeBucketResponseDto,
} from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';

View File

@ -0,0 +1,18 @@
import { AuditDeletesDto, AuditDeletesResponseDto, AuditService, AuthUserDto } from '@app/domain';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('Audit')
@Controller('audit')
@Authenticated()
@UseValidation()
export class AuditController {
constructor(private service: AuditService) {}
@Get('deletes')
getAuditDeletes(@AuthUser() authUser: AuthUserDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
return this.service.getDeletes(authUser, dto);
}
}

View File

@ -2,6 +2,7 @@ export * from './album.controller';
export * from './api-key.controller';
export * from './app.controller';
export * from './asset.controller';
export * from './audit.controller';
export * from './auth.controller';
export * from './job.controller';
export * from './oauth.controller';

View File

@ -17,6 +17,7 @@ export const databaseConfig: PostgresConnectionOptions = {
entities: [__dirname + '/entities/*.entity.{js,ts}'],
synchronize: false,
migrations: [__dirname + '/migrations/*.{js,ts}'],
subscribers: [__dirname + '/subscribers/*.{js,ts}'],
migrationsRun: true,
connectTimeoutMS: 10000, // 10 seconds
...urlOrParts,

View File

@ -0,0 +1,34 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum DatabaseAction {
CREATE = 'CREATE',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}
export enum EntityType {
ASSET = 'ASSET',
ALBUM = 'ALBUM',
}
@Entity('audit')
@Index('IDX_ownerId_createdAt', ['ownerId', 'createdAt'])
export class AuditEntity {
@PrimaryGeneratedColumn('increment')
id!: number;
@Column()
entityType!: EntityType;
@Column({ type: 'uuid' })
entityId!: string;
@Column()
action!: DatabaseAction;
@Column({ type: 'uuid' })
ownerId!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -2,6 +2,7 @@ import { AlbumEntity } from './album.entity';
import { APIKeyEntity } from './api-key.entity';
import { AssetFaceEntity } from './asset-face.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { PartnerEntity } from './partner.entity';
import { PersonEntity } from './person.entity';
import { SharedLinkEntity } from './shared-link.entity';
@ -15,6 +16,7 @@ export * from './album.entity';
export * from './api-key.entity';
export * from './asset-face.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
export * from './partner.entity';
export * from './person.entity';
@ -30,6 +32,7 @@ export const databaseEntities = [
APIKeyEntity,
AssetEntity,
AssetFaceEntity,
AuditEntity,
PartnerEntity,
PersonEntity,
SharedLinkEntity,

View File

@ -2,6 +2,7 @@ import {
IAccessRepository,
IAlbumRepository,
IAssetRepository,
IAuditRepository,
ICommunicationRepository,
ICryptoRepository,
IFaceRepository,
@ -35,6 +36,7 @@ import {
AlbumRepository,
APIKeyRepository,
AssetRepository,
AuditRepository,
CommunicationRepository,
CryptoRepository,
FaceRepository,
@ -58,6 +60,7 @@ const providers: Provider[] = [
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAuditRepository, useClass: AuditRepository },
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IFaceRepository, useClass: FaceRepository },

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAuditTable1692804658140 implements MigrationInterface {
name = 'AddAuditTable1692804658140'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "audit" ("id" SERIAL NOT NULL, "entityType" character varying NOT NULL, "entityId" uuid NOT NULL, "action" character varying NOT NULL, "ownerId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_1d3d120ddaf7bc9b1ed68ed463a" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_ownerId_createdAt" ON "audit" ("ownerId", "createdAt") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_ownerId_createdAt"`);
await queryRunner.query(`DROP TABLE "audit"`);
}
}

View File

@ -0,0 +1,26 @@
import { AuditSearch, IAuditRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, MoreThan, Repository } from 'typeorm';
import { AuditEntity } from '../entities';
export class AuditRepository implements IAuditRepository {
constructor(@InjectRepository(AuditEntity) private repository: Repository<AuditEntity>) {}
getAfter(since: Date, options: AuditSearch): Promise<AuditEntity[]> {
return this.repository
.createQueryBuilder('audit')
.where({
createdAt: MoreThan(since),
action: options.action,
entityType: options.entityType,
ownerId: options.ownerId,
})
.distinctOn(['audit.entityId', 'audit.entityType'])
.orderBy('audit.entityId, audit.entityType, audit.createdAt', 'DESC')
.getMany();
}
async removeBefore(before: Date): Promise<void> {
await this.repository.delete({ createdAt: LessThan(before) });
}
}

View File

@ -2,6 +2,7 @@ export * from './access.repository';
export * from './album.repository';
export * from './api-key.repository';
export * from './asset.repository';
export * from './audit.repository';
export * from './communication.repository';
export * from './crypto.repository';
export * from './face.repository';

View File

@ -0,0 +1,38 @@
import { EntitySubscriberInterface, EventSubscriber, RemoveEvent } from 'typeorm';
import { AlbumEntity, AssetEntity, AuditEntity, DatabaseAction, EntityType } from '../entities';
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface<AssetEntity | AlbumEntity> {
async afterRemove(event: RemoveEvent<AssetEntity>): Promise<void> {
await this.onEvent(DatabaseAction.DELETE, event);
}
private async onEvent<T>(action: DatabaseAction, event: RemoveEvent<T>): Promise<any> {
const audit = this.getAudit(event.metadata.name, { ...event.entity, id: event.entityId });
if (audit && audit.entityId && audit.ownerId) {
await event.manager.getRepository(AuditEntity).save({ ...audit, action });
}
}
private getAudit(entityName: string, entity: any): Partial<AuditEntity> | null {
switch (entityName) {
case AssetEntity.name:
const asset = entity as AssetEntity;
return {
entityType: EntityType.ASSET,
entityId: asset.id,
ownerId: asset.ownerId,
};
case AlbumEntity.name:
const album = entity as AlbumEntity;
return {
entityType: EntityType.ALBUM,
entityId: album.id,
ownerId: album.ownerId,
};
}
return null;
}
}

View File

@ -1,4 +1,5 @@
import {
AuditService,
FacialRecognitionService,
IDeleteFilesJob,
JobName,
@ -35,11 +36,13 @@ export class AppService {
private storageService: StorageService,
private systemConfigService: SystemConfigService,
private userService: UserService,
private auditService: AuditService,
) {}
async init() {
await this.jobService.registerHandlers({
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),

View File

@ -408,7 +408,11 @@ export class MetadataExtractionProcessor {
}
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt || undefined });
await this.assetRepository.save({
id: asset.id,
fileCreatedAt: fileCreatedAt || undefined,
updatedAt: new Date(),
});
return true;
}

29
server/test/fixtures/audit.stub.ts vendored Normal file
View File

@ -0,0 +1,29 @@
import { AuditEntity, DatabaseAction, EntityType } from '@app/infra/entities';
import { authStub } from './auth.stub';
export const auditStub = {
create: Object.freeze<AuditEntity>({
id: 1,
entityId: 'asset-created',
action: DatabaseAction.CREATE,
entityType: EntityType.ASSET,
ownerId: authStub.admin.id,
createdAt: new Date(),
}),
update: Object.freeze<AuditEntity>({
id: 2,
entityId: 'asset-updated',
action: DatabaseAction.UPDATE,
entityType: EntityType.ASSET,
ownerId: authStub.admin.id,
createdAt: new Date(),
}),
delete: Object.freeze<AuditEntity>({
id: 3,
entityId: 'asset-deleted',
action: DatabaseAction.DELETE,
entityType: EntityType.ASSET,
ownerId: authStub.admin.id,
createdAt: new Date(),
}),
};

View File

@ -1,6 +1,7 @@
export * from './album.stub';
export * from './api-key.stub';
export * from './asset.stub';
export * from './audit.stub';
export * from './auth.stub';
export * from './device.stub';
export * from './error.stub';

View File

@ -1,10 +1,10 @@
import { IAccessRepository } from '@app/domain';
export type IAccessRepositoryMock = {
export interface IAccessRepositoryMock {
asset: jest.Mocked<IAccessRepository['asset']>;
album: jest.Mocked<IAccessRepository['album']>;
library: jest.Mocked<IAccessRepository['library']>;
};
}
export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
return {

View File

@ -0,0 +1,8 @@
import { IAuditRepository } from '@app/domain';
export const newAuditRepositoryMock = (): jest.Mocked<IAuditRepository> => {
return {
getAfter: jest.fn(),
removeBefore: jest.fn(),
};
};

View File

@ -2,6 +2,7 @@ export * from './access.repository.mock';
export * from './album.repository.mock';
export * from './api-key.repository.mock';
export * from './asset.repository.mock';
export * from './audit.repository.mock';
export * from './communication.repository.mock';
export * from './crypto.repository.mock';
export * from './face.repository.mock';

View File

@ -752,6 +752,25 @@ export const AudioCodec = {
export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec];
/**
*
* @export
* @interface AuditDeletesResponseDto
*/
export interface AuditDeletesResponseDto {
/**
*
* @type {Array<string>}
* @memberof AuditDeletesResponseDto
*/
'ids': Array<string>;
/**
*
* @type {boolean}
* @memberof AuditDeletesResponseDto
*/
'needsFullSync': boolean;
}
/**
*
* @export
@ -1243,6 +1262,20 @@ export interface DownloadResponseDto {
*/
'totalSize': number;
}
/**
*
* @export
* @enum {string}
*/
export const EntityType = {
Asset: 'ASSET',
Album: 'ALBUM'
} as const;
export type EntityType = typeof EntityType[keyof typeof EntityType];
/**
*
* @export
@ -5120,11 +5153,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {boolean} [isArchived]
* @param {boolean} [withoutThumbs] Include assets without thumbnails
* @param {number} [skip]
* @param {string} [updatedAfter]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -5166,6 +5200,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['skip'] = skip;
}
if (updatedAfter !== undefined) {
localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ?
(updatedAfter as any).toISOString() :
updatedAfter;
}
if (ifNoneMatch != null) {
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
}
@ -6274,12 +6314,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {boolean} [isArchived]
* @param {boolean} [withoutThumbs] Include assets without thumbnails
* @param {number} [skip]
* @param {string} [updatedAfter]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options);
async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, updatedAfter, ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -6576,7 +6617,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @throws {RequiredError}
*/
getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath));
return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath));
},
/**
* Get a single asset\'s information
@ -6895,6 +6936,13 @@ export interface AssetApiGetAllAssetsRequest {
*/
readonly skip?: number
/**
*
* @type {string}
* @memberof AssetApiGetAllAssets
*/
readonly updatedAfter?: string
/**
* ETag of data already cached on the client
* @type {string}
@ -7460,7 +7508,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi
*/
public getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -7671,6 +7719,163 @@ export class AssetApi extends BaseAPI {
}
/**
* AuditApi - axios parameter creator
* @export
*/
export const AuditApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {EntityType} entityType
* @param {string} after
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuditDeletes: async (entityType: EntityType, after: string, userId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'entityType' is not null or undefined
assertParamExists('getAuditDeletes', 'entityType', entityType)
// verify required parameter 'after' is not null or undefined
assertParamExists('getAuditDeletes', 'after', after)
const localVarPath = `/audit/deletes`;
// 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;
// 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)
if (entityType !== undefined) {
localVarQueryParameter['entityType'] = entityType;
}
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
if (after !== undefined) {
localVarQueryParameter['after'] = (after as any instanceof Date) ?
(after as any).toISOString() :
after;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* AuditApi - functional programming interface
* @export
*/
export const AuditApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = AuditApiAxiosParamCreator(configuration)
return {
/**
*
* @param {EntityType} entityType
* @param {string} after
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAuditDeletes(entityType: EntityType, after: string, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AuditDeletesResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditDeletes(entityType, after, userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* AuditApi - factory interface
* @export
*/
export const AuditApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = AuditApiFp(configuration)
return {
/**
*
* @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig): AxiosPromise<AuditDeletesResponseDto> {
return localVarFp.getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for getAuditDeletes operation in AuditApi.
* @export
* @interface AuditApiGetAuditDeletesRequest
*/
export interface AuditApiGetAuditDeletesRequest {
/**
*
* @type {EntityType}
* @memberof AuditApiGetAuditDeletes
*/
readonly entityType: EntityType
/**
*
* @type {string}
* @memberof AuditApiGetAuditDeletes
*/
readonly after: string
/**
*
* @type {string}
* @memberof AuditApiGetAuditDeletes
*/
readonly userId?: string
}
/**
* AuditApi - object-oriented interface
* @export
* @class AuditApi
* @extends {BaseAPI}
*/
export class AuditApi extends BaseAPI {
/**
*
* @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuditApi
*/
public getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig) {
return AuditApiFp(this.configuration).getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* AuthenticationApi - axios parameter creator
* @export