Merge branch 'main' into main

This commit is contained in:
Lena Tauchner 2024-08-17 14:51:07 +02:00 committed by GitHub
commit 5c927856b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 836 additions and 152 deletions

View File

@ -73,14 +73,14 @@ You can edit the link properties, options include;
- **Allow public user to download -** whether to allow whoever has the link to download all the images or a certain image (optional).
- **Allow public user to upload -** whether to allow whoever has the link to upload assets to the album (optional).
:::info
whoever has the link and have uploaded files cannot delete them.
Whoever has the link and have uploaded files cannot delete them.
:::
- **Expire after -** adding an expiration date to the link (optional).
## Share Specific Assets
A user can share specific assets without linking them to a specific album.
in order to do so;
In order to do this:
1. Go to the timeline
2. Select the assets (Shift can be used for multiple selection)

View File

@ -4,7 +4,7 @@ sidebar_position: 10
# Requirements
Hardware and software requirements for Immich
Hardware and software requirements for Immich:
## Software

View File

@ -45,7 +45,7 @@ width="70%"
alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
/>
3. Select the cog ⚙️ next to Immich then click "**Edit Stack**"
3. Select the cogwheel ⚙️ next to Immich and click "**Edit Stack**"
4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. Note that Unraid v6.12.10 uses version 24.0.9 of the Docker Engine, which does not support healthcheck `start_interval` as defined in the `database` service of the Docker compose file (version 25 or higher is needed). This parameter defines an initial waiting period before starting health checks, to give the container time to start up. Commenting out the `start_interval` and `start_period` parameters will allow the containers to start up normally. The only downside to this is that the database container will not receive an initial health check until `interval` time has passed.
<details >
@ -130,7 +130,7 @@ For more information on how to use the application once installed, please refer
## Updating Steps
Updating is extremely easy however it's important to be aware that containers managed via the Docker Compose Manager plugin do not integrate with Unraid's native dockerman ui, the label "_update ready_" will always be present on containers installed via the Docker Compose Manager.
Updating is extremely easy however it's important to be aware that containers managed via the Docker Compose Manager plugin do not integrate with Unraid's native dockerman UI, the label "_update ready_" will always be present on containers installed via the Docker Compose Manager.
<img
src={require('./img/unraid09.png').default}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,12 +1,12 @@
import { LoginResponseDto, createApiKey } from '@immich/sdk';
import { LoginResponseDto, Permission, createApiKey } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const create = (accessToken: string) =>
createApiKey({ apiKeyCreateDto: { name: 'api key' } }, { headers: asBearerAuth(accessToken) });
const create = (accessToken: string, permissions: Permission[]) =>
createApiKey({ apiKeyCreateDto: { name: 'api key', permissions } }, { headers: asBearerAuth(accessToken) });
describe('/api-keys', () => {
let admin: LoginResponseDto;
@ -30,15 +30,65 @@ describe('/api-keys', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should not work without permission', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]);
const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('apiKey.create'));
});
it('should work with apiKey.create', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({
name: 'API Key',
permissions: [Permission.ApiKeyRead],
});
expect(body).toEqual({
secret: expect.any(String),
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.ApiKeyRead],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
});
expect(status).toBe(201);
});
it('should not create an api key with all permissions', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({ name: 'API Key', permissions: [Permission.All] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have'));
});
it('should not create an api key with more permissions', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({ name: 'API Key', permissions: [Permission.ApiKeyRead] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have'));
});
it('should create an api key', async () => {
const { status, body } = await request(app)
.post('/api-keys')
.send({ name: 'API Key' })
.send({ name: 'API Key', permissions: [Permission.All] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual({
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
@ -63,9 +113,9 @@ describe('/api-keys', () => {
it('should return a list of api keys', async () => {
const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([
create(admin.accessToken),
create(admin.accessToken),
create(admin.accessToken),
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
]);
const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(3);
@ -82,7 +132,7 @@ describe('/api-keys', () => {
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -99,7 +149,7 @@ describe('/api-keys', () => {
});
it('should get api key details', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
@ -107,6 +157,7 @@ describe('/api-keys', () => {
expect(body).toEqual({
id: expect.any(String),
name: 'api key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
@ -121,7 +172,7 @@ describe('/api-keys', () => {
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name' })
@ -140,7 +191,7 @@ describe('/api-keys', () => {
});
it('should update api key details', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name' })
@ -149,6 +200,7 @@ describe('/api-keys', () => {
expect(body).toEqual({
id: expect.any(String),
name: 'new name',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
@ -163,7 +215,7 @@ describe('/api-keys', () => {
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -180,7 +232,7 @@ describe('/api-keys', () => {
});
it('should delete an api key', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
@ -190,14 +242,14 @@ describe('/api-keys', () => {
describe('authentication', () => {
it('should work as a header', async () => {
const { secret } = await create(admin.accessToken);
const { secret } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app).get('/api-keys').set('x-api-key', secret);
expect(body).toHaveLength(1);
expect(status).toBe(200);
});
it('should work as a query param', async () => {
const { secret } = await create(admin.accessToken);
const { secret } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app).get(`/api-keys?apiKey=${secret}`);
expect(body).toHaveLength(1);
expect(status).toBe(200);

View File

@ -1,3 +1,4 @@
import { Permission } from '@immich/sdk';
import { stat } from 'node:fs/promises';
import { app, immichCli, utils } from 'src/utils';
import { beforeEach, describe, expect, it } from 'vitest';
@ -29,7 +30,7 @@ describe(`immich login`, () => {
it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283/api',
@ -46,7 +47,7 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283',

View File

@ -13,6 +13,12 @@ export const errorDto = {
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,

View File

@ -7,6 +7,7 @@ import {
CreateAlbumDto,
CreateLibraryDto,
MetadataSearchDto,
Permission,
PersonCreateDto,
SharedLinkCreateDto,
UserAdminCreateDto,
@ -279,8 +280,8 @@ export const utils = {
});
},
createApiKey: (accessToken: string) => {
return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) });
createApiKey: (accessToken: string, permissions: Permission[]) => {
return createApiKey({ apiKeyCreateDto: { name: 'e2e', permissions } }, { headers: asBearerAuth(accessToken) });
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
@ -492,7 +493,7 @@ export const utils = {
},
cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken);
const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, `${key.secret}`]);
return key.secret;
},

View File

@ -55,13 +55,13 @@
"asset_list_settings_subtitle": "Photo grid layout settings",
"asset_list_settings_title": "Photo Grid",
"asset_restored_successfully": "Asset restored successfully",
"asset_viewer_settings_title": "Asset Viewer",
"assets_deleted_permanently": "{} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server",
"assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device",
"assets_restored_successfully": "{} asset(s) restored successfully",
"assets_trashed": "{} asset(s) trashed",
"assets_trashed_from_server": "{} asset(s) trashed from the Immich server",
"asset_viewer_settings_title": "Asset Viewer",
"backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
@ -173,6 +173,7 @@
"control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_delete_from_immich": "Delete from Immich",
"control_bottom_app_bar_delete_from_local": "Delete from device",
"control_bottom_app_bar_download": "Download",
"control_bottom_app_bar_edit": "Edit",
"control_bottom_app_bar_edit_location": "Edit Location",
"control_bottom_app_bar_edit_time": "Edit Date & Time",
@ -455,15 +456,18 @@
"setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
"setting_notifications_total_progress_title": "Show background backup total progress",
"setting_pages_app_bar_settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting",
"setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.",
"setting_video_viewer_looping_title": "Looping",
"setting_video_viewer_title": "Videos",
"settings_require_restart": "Please restart Immich to apply this setting",
"share_add": "Add",
"share_add_photos": "Add photos",
"share_add_title": "Add a title",
"share_assets_selected": "{} selected",
"share_create_album": "Create album",
"share_dialog_preparing": "Preparing...",
"share_done": "Done",
"share_invite": "Invite to album",
"shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activities_input_hint": "Say something",
"shared_album_activity_remove_content": "Do you want to delete this activity?",
@ -475,7 +479,6 @@
"shared_album_section_people_action_remove_user": "Remove user from album",
"shared_album_section_people_owner_label": "Owner",
"shared_album_section_people_title": "PEOPLE",
"share_dialog_preparing": "Preparing...",
"shared_link_app_bar_title": "Shared Links",
"shared_link_clipboard_copied_massage": "Copied to clipboard",
"shared_link_clipboard_text": "Link: {}\nPassword: {}",
@ -521,14 +524,12 @@
"shared_link_info_chip_upload": "Upload",
"shared_link_manage_links": "Manage Shared links",
"shared_link_public_album": "Public album",
"share_done": "Done",
"share_invite": "Invite to album",
"sharing_page_album": "Shared albums",
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
"sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "New shared album",
"sharing_silver_appbar_shared_links": "Shared links",
"sharing_silver_appbar_share_partner": "Share with partner",
"sharing_silver_appbar_shared_links": "Shared links",
"tab_controller_nav_library": "Library",
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Search",

View File

@ -51,8 +51,8 @@ class BackupControllerPage extends HookConsumerWidget {
}
void stopScreenDarkenTimer() {
isScreenDarkened.value = false;
darkenScreenTimer.value?.cancel();
isScreenDarkened.value = false;
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: [
@ -75,8 +75,6 @@ class BackupControllerPage extends HookConsumerWidget {
.watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success');
WakelockPlus.enable();
return () {
WakelockPlus.disable();
darkenScreenTimer.value?.cancel();
@ -102,8 +100,10 @@ class BackupControllerPage extends HookConsumerWidget {
() {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
startScreenDarkenTimer();
WakelockPlus.enable();
} else {
stopScreenDarkenTimer();
WakelockPlus.disable();
}
return null;

View File

@ -20,7 +20,7 @@ import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart';
import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:photo_manager/photo_manager.dart';
final backupServiceProvider = Provider(
@ -213,7 +213,7 @@ class BackupService {
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
if (Platform.isAndroid &&
!(await Permission.accessMediaLocation.status).isGranted) {
!(await pm.Permission.accessMediaLocation.status).isGranted) {
// double check that permission is granted here, to guard against
// uploading corrupt assets without EXIF information
_log.warning("Media location permission is not granted. "

View File

@ -29,7 +29,8 @@ class LocalNotificationService {
static const cancelUploadActionID = 'cancel_upload';
Future<void> setup() async {
const androidSetting = AndroidInitializationSettings('notification_icon');
const androidSetting =
AndroidInitializationSettings('@drawable/notification_icon');
const iosSetting = DarwinInitializationSettings();
const initSettings =

View File

@ -366,8 +366,8 @@ class BottomGalleryBar extends ConsumerWidget {
{
BottomNavigationBarItem(
icon: const Icon(Icons.download_outlined),
label: 'download'.tr(),
tooltip: 'download'.tr(),
label: 'control_bottom_app_bar_download'.tr(),
tooltip: 'control_bottom_app_bar_download'.tr(),
): (_) => handleDownload(),
},
if (isInAlbum)

View File

@ -363,6 +363,7 @@ Class | Method | HTTP request | Description
- [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PeopleUpdateDto](doc//PeopleUpdateDto.md)
- [PeopleUpdateItem](doc//PeopleUpdateItem.md)
- [Permission](doc//Permission.md)
- [PersonCreateDto](doc//PersonCreateDto.md)
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)

View File

@ -175,6 +175,7 @@ part 'model/path_type.dart';
part 'model/people_response_dto.dart';
part 'model/people_update_dto.dart';
part 'model/people_update_item.dart';
part 'model/permission.dart';
part 'model/person_create_dto.dart';
part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart';

View File

@ -407,6 +407,8 @@ class ApiClient {
return PeopleUpdateDto.fromJson(value);
case 'PeopleUpdateItem':
return PeopleUpdateItem.fromJson(value);
case 'Permission':
return PermissionTypeTransformer().decode(value);
case 'PersonCreateDto':
return PersonCreateDto.fromJson(value);
case 'PersonResponseDto':

View File

@ -112,6 +112,9 @@ String parameterToString(dynamic value) {
if (value is PathType) {
return PathTypeTypeTransformer().encode(value).toString();
}
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is ReactionLevel) {
return ReactionLevelTypeTransformer().encode(value).toString();
}

View File

@ -14,6 +14,7 @@ class APIKeyCreateDto {
/// Returns a new [APIKeyCreateDto] instance.
APIKeyCreateDto({
this.name,
this.permissions = const [],
});
///
@ -24,17 +25,21 @@ class APIKeyCreateDto {
///
String? name;
List<Permission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is APIKeyCreateDto &&
other.name == name;
other.name == name &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(name == null ? 0 : name!.hashCode);
(name == null ? 0 : name!.hashCode) +
(permissions.hashCode);
@override
String toString() => 'APIKeyCreateDto[name=$name]';
String toString() => 'APIKeyCreateDto[name=$name, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -43,6 +48,7 @@ class APIKeyCreateDto {
} else {
// json[r'name'] = null;
}
json[r'permissions'] = this.permissions;
return json;
}
@ -55,6 +61,7 @@ class APIKeyCreateDto {
return APIKeyCreateDto(
name: mapValueOfType<String>(json, r'name'),
permissions: Permission.listFromJson(json[r'permissions']),
);
}
return null;
@ -102,6 +109,7 @@ class APIKeyCreateDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'permissions',
};
}

View File

@ -16,6 +16,7 @@ class APIKeyResponseDto {
required this.createdAt,
required this.id,
required this.name,
this.permissions = const [],
required this.updatedAt,
});
@ -25,6 +26,8 @@ class APIKeyResponseDto {
String name;
List<Permission> permissions;
DateTime updatedAt;
@override
@ -32,6 +35,7 @@ class APIKeyResponseDto {
other.createdAt == createdAt &&
other.id == id &&
other.name == name &&
_deepEquality.equals(other.permissions, permissions) &&
other.updatedAt == updatedAt;
@override
@ -40,16 +44,18 @@ class APIKeyResponseDto {
(createdAt.hashCode) +
(id.hashCode) +
(name.hashCode) +
(permissions.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt]';
String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, permissions=$permissions, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'permissions'] = this.permissions;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
return json;
}
@ -65,6 +71,7 @@ class APIKeyResponseDto {
createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
permissions: Permission.listFromJson(json[r'permissions']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
}
@ -116,6 +123,7 @@ class APIKeyResponseDto {
'createdAt',
'id',
'name',
'permissions',
'updatedAt',
};
}

292
mobile/openapi/lib/model/permission.dart generated Normal file
View File

@ -0,0 +1,292 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 Permission {
/// Instantiate a new enum with the provided [value].
const Permission._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const all = Permission._(r'all');
static const activityPeriodCreate = Permission._(r'activity.create');
static const activityPeriodRead = Permission._(r'activity.read');
static const activityPeriodUpdate = Permission._(r'activity.update');
static const activityPeriodDelete = Permission._(r'activity.delete');
static const activityPeriodStatistics = Permission._(r'activity.statistics');
static const apiKeyPeriodCreate = Permission._(r'apiKey.create');
static const apiKeyPeriodRead = Permission._(r'apiKey.read');
static const apiKeyPeriodUpdate = Permission._(r'apiKey.update');
static const apiKeyPeriodDelete = Permission._(r'apiKey.delete');
static const assetPeriodRead = Permission._(r'asset.read');
static const assetPeriodUpdate = Permission._(r'asset.update');
static const assetPeriodDelete = Permission._(r'asset.delete');
static const assetPeriodRestore = Permission._(r'asset.restore');
static const assetPeriodShare = Permission._(r'asset.share');
static const assetPeriodView = Permission._(r'asset.view');
static const assetPeriodDownload = Permission._(r'asset.download');
static const assetPeriodUpload = Permission._(r'asset.upload');
static const albumPeriodCreate = Permission._(r'album.create');
static const albumPeriodRead = Permission._(r'album.read');
static const albumPeriodUpdate = Permission._(r'album.update');
static const albumPeriodDelete = Permission._(r'album.delete');
static const albumPeriodStatistics = Permission._(r'album.statistics');
static const albumPeriodAddAsset = Permission._(r'album.addAsset');
static const albumPeriodRemoveAsset = Permission._(r'album.removeAsset');
static const albumPeriodShare = Permission._(r'album.share');
static const albumPeriodDownload = Permission._(r'album.download');
static const authDevicePeriodDelete = Permission._(r'authDevice.delete');
static const archivePeriodRead = Permission._(r'archive.read');
static const facePeriodCreate = Permission._(r'face.create');
static const facePeriodRead = Permission._(r'face.read');
static const facePeriodUpdate = Permission._(r'face.update');
static const facePeriodDelete = Permission._(r'face.delete');
static const libraryPeriodCreate = Permission._(r'library.create');
static const libraryPeriodRead = Permission._(r'library.read');
static const libraryPeriodUpdate = Permission._(r'library.update');
static const libraryPeriodDelete = Permission._(r'library.delete');
static const libraryPeriodStatistics = Permission._(r'library.statistics');
static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download');
static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
static const memoryPeriodDelete = Permission._(r'memory.delete');
static const partnerPeriodCreate = Permission._(r'partner.create');
static const partnerPeriodRead = Permission._(r'partner.read');
static const partnerPeriodUpdate = Permission._(r'partner.update');
static const partnerPeriodDelete = Permission._(r'partner.delete');
static const personPeriodCreate = Permission._(r'person.create');
static const personPeriodRead = Permission._(r'person.read');
static const personPeriodUpdate = Permission._(r'person.update');
static const personPeriodDelete = Permission._(r'person.delete');
static const personPeriodStatistics = Permission._(r'person.statistics');
static const personPeriodMerge = Permission._(r'person.merge');
static const personPeriodReassign = Permission._(r'person.reassign');
static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create');
static const sharedLinkPeriodRead = Permission._(r'sharedLink.read');
static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update');
static const sharedLinkPeriodDelete = Permission._(r'sharedLink.delete');
static const systemConfigPeriodRead = Permission._(r'systemConfig.read');
static const systemConfigPeriodUpdate = Permission._(r'systemConfig.update');
static const systemMetadataPeriodRead = Permission._(r'systemMetadata.read');
static const systemMetadataPeriodUpdate = Permission._(r'systemMetadata.update');
static const tagPeriodCreate = Permission._(r'tag.create');
static const tagPeriodRead = Permission._(r'tag.read');
static const tagPeriodUpdate = Permission._(r'tag.update');
static const tagPeriodDelete = Permission._(r'tag.delete');
static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create');
static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read');
static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update');
static const adminPeriodUserPeriodDelete = Permission._(r'admin.user.delete');
/// List of all possible values in this [enum][Permission].
static const values = <Permission>[
all,
activityPeriodCreate,
activityPeriodRead,
activityPeriodUpdate,
activityPeriodDelete,
activityPeriodStatistics,
apiKeyPeriodCreate,
apiKeyPeriodRead,
apiKeyPeriodUpdate,
apiKeyPeriodDelete,
assetPeriodRead,
assetPeriodUpdate,
assetPeriodDelete,
assetPeriodRestore,
assetPeriodShare,
assetPeriodView,
assetPeriodDownload,
assetPeriodUpload,
albumPeriodCreate,
albumPeriodRead,
albumPeriodUpdate,
albumPeriodDelete,
albumPeriodStatistics,
albumPeriodAddAsset,
albumPeriodRemoveAsset,
albumPeriodShare,
albumPeriodDownload,
authDevicePeriodDelete,
archivePeriodRead,
facePeriodCreate,
facePeriodRead,
facePeriodUpdate,
facePeriodDelete,
libraryPeriodCreate,
libraryPeriodRead,
libraryPeriodUpdate,
libraryPeriodDelete,
libraryPeriodStatistics,
timelinePeriodRead,
timelinePeriodDownload,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
memoryPeriodDelete,
partnerPeriodCreate,
partnerPeriodRead,
partnerPeriodUpdate,
partnerPeriodDelete,
personPeriodCreate,
personPeriodRead,
personPeriodUpdate,
personPeriodDelete,
personPeriodStatistics,
personPeriodMerge,
personPeriodReassign,
sharedLinkPeriodCreate,
sharedLinkPeriodRead,
sharedLinkPeriodUpdate,
sharedLinkPeriodDelete,
systemConfigPeriodRead,
systemConfigPeriodUpdate,
systemMetadataPeriodRead,
systemMetadataPeriodUpdate,
tagPeriodCreate,
tagPeriodRead,
tagPeriodUpdate,
tagPeriodDelete,
adminPeriodUserPeriodCreate,
adminPeriodUserPeriodRead,
adminPeriodUserPeriodUpdate,
adminPeriodUserPeriodDelete,
];
static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value);
static List<Permission> listFromJson(dynamic json, {bool growable = false,}) {
final result = <Permission>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = Permission.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [Permission] to String,
/// and [decode] dynamic data back to [Permission].
class PermissionTypeTransformer {
factory PermissionTypeTransformer() => _instance ??= const PermissionTypeTransformer._();
const PermissionTypeTransformer._();
String encode(Permission data) => data.value;
/// Decodes a [dynamic value][data] to a Permission.
///
/// 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.
Permission? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'all': return Permission.all;
case r'activity.create': return Permission.activityPeriodCreate;
case r'activity.read': return Permission.activityPeriodRead;
case r'activity.update': return Permission.activityPeriodUpdate;
case r'activity.delete': return Permission.activityPeriodDelete;
case r'activity.statistics': return Permission.activityPeriodStatistics;
case r'apiKey.create': return Permission.apiKeyPeriodCreate;
case r'apiKey.read': return Permission.apiKeyPeriodRead;
case r'apiKey.update': return Permission.apiKeyPeriodUpdate;
case r'apiKey.delete': return Permission.apiKeyPeriodDelete;
case r'asset.read': return Permission.assetPeriodRead;
case r'asset.update': return Permission.assetPeriodUpdate;
case r'asset.delete': return Permission.assetPeriodDelete;
case r'asset.restore': return Permission.assetPeriodRestore;
case r'asset.share': return Permission.assetPeriodShare;
case r'asset.view': return Permission.assetPeriodView;
case r'asset.download': return Permission.assetPeriodDownload;
case r'asset.upload': return Permission.assetPeriodUpload;
case r'album.create': return Permission.albumPeriodCreate;
case r'album.read': return Permission.albumPeriodRead;
case r'album.update': return Permission.albumPeriodUpdate;
case r'album.delete': return Permission.albumPeriodDelete;
case r'album.statistics': return Permission.albumPeriodStatistics;
case r'album.addAsset': return Permission.albumPeriodAddAsset;
case r'album.removeAsset': return Permission.albumPeriodRemoveAsset;
case r'album.share': return Permission.albumPeriodShare;
case r'album.download': return Permission.albumPeriodDownload;
case r'authDevice.delete': return Permission.authDevicePeriodDelete;
case r'archive.read': return Permission.archivePeriodRead;
case r'face.create': return Permission.facePeriodCreate;
case r'face.read': return Permission.facePeriodRead;
case r'face.update': return Permission.facePeriodUpdate;
case r'face.delete': return Permission.facePeriodDelete;
case r'library.create': return Permission.libraryPeriodCreate;
case r'library.read': return Permission.libraryPeriodRead;
case r'library.update': return Permission.libraryPeriodUpdate;
case r'library.delete': return Permission.libraryPeriodDelete;
case r'library.statistics': return Permission.libraryPeriodStatistics;
case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload;
case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;
case r'memory.delete': return Permission.memoryPeriodDelete;
case r'partner.create': return Permission.partnerPeriodCreate;
case r'partner.read': return Permission.partnerPeriodRead;
case r'partner.update': return Permission.partnerPeriodUpdate;
case r'partner.delete': return Permission.partnerPeriodDelete;
case r'person.create': return Permission.personPeriodCreate;
case r'person.read': return Permission.personPeriodRead;
case r'person.update': return Permission.personPeriodUpdate;
case r'person.delete': return Permission.personPeriodDelete;
case r'person.statistics': return Permission.personPeriodStatistics;
case r'person.merge': return Permission.personPeriodMerge;
case r'person.reassign': return Permission.personPeriodReassign;
case r'sharedLink.create': return Permission.sharedLinkPeriodCreate;
case r'sharedLink.read': return Permission.sharedLinkPeriodRead;
case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate;
case r'sharedLink.delete': return Permission.sharedLinkPeriodDelete;
case r'systemConfig.read': return Permission.systemConfigPeriodRead;
case r'systemConfig.update': return Permission.systemConfigPeriodUpdate;
case r'systemMetadata.read': return Permission.systemMetadataPeriodRead;
case r'systemMetadata.update': return Permission.systemMetadataPeriodUpdate;
case r'tag.create': return Permission.tagPeriodCreate;
case r'tag.read': return Permission.tagPeriodRead;
case r'tag.update': return Permission.tagPeriodUpdate;
case r'tag.delete': return Permission.tagPeriodDelete;
case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate;
case r'admin.user.read': return Permission.adminPeriodUserPeriodRead;
case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate;
case r'admin.user.delete': return Permission.adminPeriodUserPeriodDelete;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [PermissionTypeTransformer] instance.
static PermissionTypeTransformer? _instance;
}

View File

@ -7135,8 +7135,17 @@
"properties": {
"name": {
"type": "string"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/Permission"
},
"type": "array"
}
},
"required": [
"permissions"
],
"type": "object"
},
"APIKeyCreateResponseDto": {
@ -7166,6 +7175,12 @@
"name": {
"type": "string"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/Permission"
},
"type": "array"
},
"updatedAt": {
"format": "date-time",
"type": "string"
@ -7175,6 +7190,7 @@
"createdAt",
"id",
"name",
"permissions",
"updatedAt"
],
"type": "object"
@ -9729,6 +9745,82 @@
],
"type": "object"
},
"Permission": {
"enum": [
"all",
"activity.create",
"activity.read",
"activity.update",
"activity.delete",
"activity.statistics",
"apiKey.create",
"apiKey.read",
"apiKey.update",
"apiKey.delete",
"asset.read",
"asset.update",
"asset.delete",
"asset.restore",
"asset.share",
"asset.view",
"asset.download",
"asset.upload",
"album.create",
"album.read",
"album.update",
"album.delete",
"album.statistics",
"album.addAsset",
"album.removeAsset",
"album.share",
"album.download",
"authDevice.delete",
"archive.read",
"face.create",
"face.read",
"face.update",
"face.delete",
"library.create",
"library.read",
"library.update",
"library.delete",
"library.statistics",
"timeline.read",
"timeline.download",
"memory.create",
"memory.read",
"memory.update",
"memory.delete",
"partner.create",
"partner.read",
"partner.update",
"partner.delete",
"person.create",
"person.read",
"person.update",
"person.delete",
"person.statistics",
"person.merge",
"person.reassign",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
"sharedLink.delete",
"systemConfig.read",
"systemConfig.update",
"systemMetadata.read",
"systemMetadata.update",
"tag.create",
"tag.read",
"tag.update",
"tag.delete",
"admin.user.create",
"admin.user.read",
"admin.user.update",
"admin.user.delete"
],
"type": "string"
},
"PersonCreateDto": {
"properties": {
"birthDate": {

View File

@ -299,10 +299,12 @@ export type ApiKeyResponseDto = {
createdAt: string;
id: string;
name: string;
permissions: Permission[];
updatedAt: string;
};
export type ApiKeyCreateDto = {
name?: string;
permissions: Permission[];
};
export type ApiKeyCreateResponseDto = {
apiKey: ApiKeyResponseDto;
@ -3125,6 +3127,79 @@ export enum Error {
NotFound = "not_found",
Unknown = "unknown"
}
export enum Permission {
All = "all",
ActivityCreate = "activity.create",
ActivityRead = "activity.read",
ActivityUpdate = "activity.update",
ActivityDelete = "activity.delete",
ActivityStatistics = "activity.statistics",
ApiKeyCreate = "apiKey.create",
ApiKeyRead = "apiKey.read",
ApiKeyUpdate = "apiKey.update",
ApiKeyDelete = "apiKey.delete",
AssetRead = "asset.read",
AssetUpdate = "asset.update",
AssetDelete = "asset.delete",
AssetRestore = "asset.restore",
AssetShare = "asset.share",
AssetView = "asset.view",
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AlbumCreate = "album.create",
AlbumRead = "album.read",
AlbumUpdate = "album.update",
AlbumDelete = "album.delete",
AlbumStatistics = "album.statistics",
AlbumAddAsset = "album.addAsset",
AlbumRemoveAsset = "album.removeAsset",
AlbumShare = "album.share",
AlbumDownload = "album.download",
AuthDeviceDelete = "authDevice.delete",
ArchiveRead = "archive.read",
FaceCreate = "face.create",
FaceRead = "face.read",
FaceUpdate = "face.update",
FaceDelete = "face.delete",
LibraryCreate = "library.create",
LibraryRead = "library.read",
LibraryUpdate = "library.update",
LibraryDelete = "library.delete",
LibraryStatistics = "library.statistics",
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",
MemoryDelete = "memory.delete",
PartnerCreate = "partner.create",
PartnerRead = "partner.read",
PartnerUpdate = "partner.update",
PartnerDelete = "partner.delete",
PersonCreate = "person.create",
PersonRead = "person.read",
PersonUpdate = "person.update",
PersonDelete = "person.delete",
PersonStatistics = "person.statistics",
PersonMerge = "person.merge",
PersonReassign = "person.reassign",
SharedLinkCreate = "sharedLink.create",
SharedLinkRead = "sharedLink.read",
SharedLinkUpdate = "sharedLink.update",
SharedLinkDelete = "sharedLink.delete",
SystemConfigRead = "systemConfig.read",
SystemConfigUpdate = "systemConfig.update",
SystemMetadataRead = "systemMetadata.read",
SystemMetadataUpdate = "systemMetadata.update",
TagCreate = "tag.create",
TagRead = "tag.read",
TagUpdate = "tag.update",
TagDelete = "tag.delete",
AdminUserCreate = "admin.user.create",
AdminUserRead = "admin.user.read",
AdminUserUpdate = "admin.user.update",
AdminUserDelete = "admin.user.delete"
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",

View File

@ -9,6 +9,7 @@ import {
ActivityStatisticsResponseDto,
} from 'src/dtos/activity.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ActivityService } from 'src/services/activity.service';
import { UUIDParamDto } from 'src/validation';
@ -19,19 +20,19 @@ export class ActivityController {
constructor(private service: ActivityService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_READ })
getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
return this.service.getAll(auth, dto);
}
@Get('statistics')
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_STATISTICS })
getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_CREATE })
async createActivity(
@Auth() auth: AuthDto,
@Body() dto: ActivityCreateDto,
@ -46,7 +47,7 @@ export class ActivityController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_DELETE })
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@ -12,6 +12,7 @@ import {
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
@ -22,24 +23,24 @@ export class AlbumController {
constructor(private service: AlbumService) {}
@Get('count')
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_STATISTICS })
getAlbumCount(@Auth() auth: AuthDto): Promise<AlbumCountResponseDto> {
return this.service.getCount(auth);
}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_READ })
getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(auth, query);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_CREATE })
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {
return this.service.create(auth, dto);
}
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true })
@Get(':id')
getAlbumInfo(
@Auth() auth: AuthDto,
@ -50,7 +51,7 @@ export class AlbumController {
}
@Patch(':id')
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_UPDATE })
updateAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -60,7 +61,7 @@ export class AlbumController {
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_DELETE })
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}

View File

@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put }
import { ApiTags } from '@nestjs/swagger';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { APIKeyService } from 'src/services/api-key.service';
import { UUIDParamDto } from 'src/validation';
@ -12,25 +13,25 @@ export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_CREATE })
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_READ })
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_READ })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_UPDATE })
updateApiKey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -41,7 +42,7 @@ export class APIKeyController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_DELETE })
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
import { UUIDParamDto } from 'src/validation';
@ -12,13 +13,13 @@ export class FaceController {
constructor(private service: PersonService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.FACE_READ })
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
return this.service.getFacesById(auth, dto);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.FACE_UPDATE })
reassignFacesById(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@ -9,6 +9,7 @@ import {
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { LibraryService } from 'src/services/library.service';
import { UUIDParamDto } from 'src/validation';
@ -19,25 +20,25 @@ export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_READ, admin: true })
getAllLibraries(): Promise<LibraryResponseDto[]> {
return this.service.getAll();
}
@Post()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_CREATE, admin: true })
createLibrary(@Body() dto: CreateLibraryDto): Promise<LibraryResponseDto> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true })
updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
return this.service.update(id, dto);
}
@Get(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_READ, admin: true })
getLibrary(@Param() { id }: UUIDParamDto): Promise<LibraryResponseDto> {
return this.service.get(id);
}
@ -52,13 +53,13 @@ export class LibraryController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true })
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@Get(':id/statistics')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true })
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
return this.service.getStatistics(id);
}

View File

@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service';
import { UUIDParamDto } from 'src/validation';
@ -13,25 +14,25 @@ export class MemoryController {
constructor(private service: MemoryService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_READ })
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_CREATE })
createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise<MemoryResponseDto> {
return this.service.create(auth, dto);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_READ })
getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MemoryResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_UPDATE })
updateMemory(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -42,7 +43,7 @@ export class MemoryController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_DELETE })
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { Permission } from 'src/enum';
import { PartnerDirection } from 'src/interfaces/partner.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service';
@ -14,20 +15,20 @@ export class PartnerController {
@Get()
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_READ })
// TODO: remove 'direction' and convert to full query dto
getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise<PartnerResponseDto[]> {
return this.service.search(auth, dto);
}
@Post(':id')
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_CREATE })
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_UPDATE })
updatePartner(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -37,7 +38,7 @@ export class PartnerController {
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_DELETE })
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@ -16,6 +16,7 @@ import {
PersonStatisticsResponseDto,
PersonUpdateDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
@ -31,31 +32,31 @@ export class PersonController {
) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_READ })
getAllPeople(@Auth() auth: AuthDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(auth, withHidden);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_CREATE })
createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.service.create(auth, dto);
}
@Put()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_UPDATE })
updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updateAll(auth, dto);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_READ })
getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_UPDATE })
updatePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -65,14 +66,14 @@ export class PersonController {
}
@Get(':id/statistics')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_READ })
async getPersonThumbnail(
@Res() res: Response,
@Next() next: NextFunction,
@ -90,7 +91,7 @@ export class PersonController {
}
@Put(':id/reassign')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_REASSIGN })
reassignFaces(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -100,7 +101,7 @@ export class PersonController {
}
@Post(':id/merge')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_MERGE })
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@ -10,6 +10,7 @@ import {
SharedLinkPasswordDto,
SharedLinkResponseDto,
} from 'src/dtos/shared-link.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
@ -22,7 +23,7 @@ export class SharedLinkController {
constructor(private service: SharedLinkService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_READ })
getAllSharedLinks(@Auth() auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.service.getAll(auth);
}
@ -48,19 +49,19 @@ export class SharedLinkController {
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_READ })
getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<SharedLinkResponseDto> {
return this.service.get(auth, id);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_CREATE })
createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) {
return this.service.create(auth, dto);
}
@Patch(':id')
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_UPDATE })
updateSharedLink(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -70,7 +71,7 @@ export class SharedLinkController {
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_DELETE })
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@ -1,6 +1,7 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@ -10,25 +11,25 @@ export class SystemConfigController {
constructor(private service: SystemConfigService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true })
getConfig(): Promise<SystemConfigDto> {
return this.service.getConfig();
}
@Get('defaults')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true })
getConfigDefaults(): SystemConfigDto {
return this.service.getDefaults();
}
@Put()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true })
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.service.updateConfig(dto);
}
@Get('storage-template-options')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true })
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.service.getStorageTemplateOptions();
}

View File

@ -1,6 +1,7 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@ -10,20 +11,20 @@ export class SystemMetadataController {
constructor(private service: SystemMetadataService) {}
@Get('admin-onboarding')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true })
getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> {
return this.service.getAdminOnboarding();
}
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_METADATA_UPDATE, admin: true })
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
@Get('reverse-geocoding-state')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true })
getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
return this.service.getReverseGeocodingState();
}

View File

@ -5,6 +5,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TagService } from 'src/services/tag.service';
import { UUIDParamDto } from 'src/validation';
@ -15,31 +16,31 @@ export class TagController {
constructor(private service: TagService) {}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.TAG_CREATE })
createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.TAG_READ })
getAllTags(@Auth() auth: AuthDto): Promise<TagResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.TAG_READ })
getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
return this.service.getById(auth, id);
}
@Patch(':id')
@Authenticated()
@Authenticated({ permission: Permission.TAG_UPDATE })
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.TAG_DELETE })
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@ -9,6 +9,7 @@ import {
UserAdminSearchDto,
UserAdminUpdateDto,
} from 'src/dtos/user.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { UserAdminService } from 'src/services/user-admin.service';
import { UUIDParamDto } from 'src/validation';
@ -19,25 +20,25 @@ export class UserAdminController {
constructor(private service: UserAdminService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_CREATE, admin: true })
createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
return this.service.create(createUserDto);
}
@Get(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true })
updateUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -47,7 +48,7 @@ export class UserAdminController {
}
@Delete(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true })
deleteUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -57,13 +58,13 @@ export class UserAdminController {
}
@Get(':id/preferences')
@Authenticated()
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> {
return this.service.getPreferences(auth, id);
}
@Put(':id/preferences')
@Authenticated()
@Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true })
updateUserPreferencesAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@ -73,7 +74,7 @@ export class UserAdminController {
}
@Post(':id/restore')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true })
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.restore(auth, id);
}

View File

@ -256,7 +256,7 @@ export class AccessCore {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.MEMORY_WRITE: {
case Permission.MEMORY_UPDATE: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
@ -272,7 +272,7 @@ export class AccessCore {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_WRITE: {
case Permission.PERSON_UPDATE: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}

View File

@ -1,10 +1,17 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Permission } from 'src/enum';
import { Optional } from 'src/validation';
export class APIKeyCreateDto {
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
@IsEnum(Permission, { each: true })
@ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true })
@ArrayMinSize(1)
permissions!: Permission[];
}
export class APIKeyUpdateDto {
@ -23,4 +30,6 @@ export class APIKeyResponseDto {
name!: string;
createdAt!: Date;
updatedAt!: Date;
@ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true })
permissions!: Permission[];
}

View File

@ -1,4 +1,5 @@
import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('api_keys')
@ -18,6 +19,9 @@ export class APIKeyEntity {
@Column()
userId!: string;
@Column({ array: true, type: 'varchar' })
permissions!: Permission[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;

View File

@ -32,8 +32,18 @@ export enum MemoryType {
}
export enum Permission {
ALL = 'all',
ACTIVITY_CREATE = 'activity.create',
ACTIVITY_READ = 'activity.read',
ACTIVITY_UPDATE = 'activity.update',
ACTIVITY_DELETE = 'activity.delete',
ACTIVITY_STATISTICS = 'activity.statistics',
API_KEY_CREATE = 'apiKey.create',
API_KEY_READ = 'apiKey.read',
API_KEY_UPDATE = 'apiKey.update',
API_KEY_DELETE = 'apiKey.delete',
// ASSET_CREATE = 'asset.create',
ASSET_READ = 'asset.read',
@ -45,10 +55,12 @@ export enum Permission {
ASSET_DOWNLOAD = 'asset.download',
ASSET_UPLOAD = 'asset.upload',
// ALBUM_CREATE = 'album.create',
ALBUM_CREATE = 'album.create',
ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
ALBUM_STATISTICS = 'album.statistics',
ALBUM_ADD_ASSET = 'album.addAsset',
ALBUM_REMOVE_ASSET = 'album.removeAsset',
ALBUM_SHARE = 'album.share',
@ -58,20 +70,58 @@ export enum Permission {
ARCHIVE_READ = 'archive.read',
FACE_CREATE = 'face.create',
FACE_READ = 'face.read',
FACE_UPDATE = 'face.update',
FACE_DELETE = 'face.delete',
LIBRARY_CREATE = 'library.create',
LIBRARY_READ = 'library.read',
LIBRARY_UPDATE = 'library.update',
LIBRARY_DELETE = 'library.delete',
LIBRARY_STATISTICS = 'library.statistics',
TIMELINE_READ = 'timeline.read',
TIMELINE_DOWNLOAD = 'timeline.download',
MEMORY_CREATE = 'memory.create',
MEMORY_READ = 'memory.read',
MEMORY_WRITE = 'memory.write',
MEMORY_UPDATE = 'memory.update',
MEMORY_DELETE = 'memory.delete',
PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge',
PARTNER_CREATE = 'partner.create',
PARTNER_READ = 'partner.read',
PARTNER_UPDATE = 'partner.update',
PARTNER_DELETE = 'partner.delete',
PERSON_CREATE = 'person.create',
PERSON_READ = 'person.read',
PERSON_UPDATE = 'person.update',
PERSON_DELETE = 'person.delete',
PERSON_STATISTICS = 'person.statistics',
PERSON_MERGE = 'person.merge',
PERSON_REASSIGN = 'person.reassign',
PARTNER_UPDATE = 'partner.update',
SHARED_LINK_CREATE = 'sharedLink.create',
SHARED_LINK_READ = 'sharedLink.read',
SHARED_LINK_UPDATE = 'sharedLink.update',
SHARED_LINK_DELETE = 'sharedLink.delete',
SYSTEM_CONFIG_READ = 'systemConfig.read',
SYSTEM_CONFIG_UPDATE = 'systemConfig.update',
SYSTEM_METADATA_READ = 'systemMetadata.read',
SYSTEM_METADATA_UPDATE = 'systemMetadata.update',
TAG_CREATE = 'tag.create',
TAG_READ = 'tag.read',
TAG_UPDATE = 'tag.update',
TAG_DELETE = 'tag.delete',
ADMIN_USER_CREATE = 'admin.user.create',
ADMIN_USER_READ = 'admin.user.read',
ADMIN_USER_UPDATE = 'admin.user.update',
ADMIN_USER_DELETE = 'admin.user.delete',
}
export enum SharedLinkType {

View File

@ -11,6 +11,7 @@ import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UAParser } from 'ua-parser-js';
@ -25,7 +26,7 @@ export enum Metadata {
type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = AdminRoute | SharedLinkRoute;
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator => {
const decorators: MethodDecorator[] = [
@ -89,13 +90,17 @@ export class AuthGuard implements CanActivate {
return true;
}
const { admin: adminRoute, sharedLink: sharedLinkRoute } = { sharedLink: false, admin: false, ...options };
const {
admin: adminRoute,
sharedLink: sharedLinkRoute,
permission,
} = { sharedLink: false, admin: false, ...options };
const request = context.switchToHttp().getRequest<AuthRequest>();
request.user = await this.authService.authenticate({
headers: request.headers,
queryParams: request.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, uri: request.path },
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
});
return true;

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddApiKeyPermissions1723719333525 implements MigrationInterface {
name = 'AddApiKeyPermissions1723719333525';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "api_keys" ADD "permissions" character varying array NOT NULL DEFAULT '{all}'`);
await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "permissions" DROP DEFAULT`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "permissions"`);
}
}

View File

@ -9,6 +9,7 @@ FROM
"APIKeyEntity"."id" AS "APIKeyEntity_id",
"APIKeyEntity"."key" AS "APIKeyEntity_key",
"APIKeyEntity"."userId" AS "APIKeyEntity_userId",
"APIKeyEntity"."permissions" AS "APIKeyEntity_permissions",
"APIKeyEntity__APIKeyEntity_user"."id" AS "APIKeyEntity__APIKeyEntity_user_id",
"APIKeyEntity__APIKeyEntity_user"."name" AS "APIKeyEntity__APIKeyEntity_user_name",
"APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin",
@ -46,6 +47,7 @@ SELECT
"APIKeyEntity"."id" AS "APIKeyEntity_id",
"APIKeyEntity"."name" AS "APIKeyEntity_name",
"APIKeyEntity"."userId" AS "APIKeyEntity_userId",
"APIKeyEntity"."permissions" AS "APIKeyEntity_permissions",
"APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt",
"APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt"
FROM
@ -63,6 +65,7 @@ SELECT
"APIKeyEntity"."id" AS "APIKeyEntity_id",
"APIKeyEntity"."name" AS "APIKeyEntity_name",
"APIKeyEntity"."userId" AS "APIKeyEntity_userId",
"APIKeyEntity"."permissions" AS "APIKeyEntity_permissions",
"APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt",
"APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt"
FROM

View File

@ -31,6 +31,7 @@ export class ApiKeyRepository implements IKeyRepository {
id: true,
key: true,
userId: true,
permissions: true,
},
where: { key: hashedToken },
relations: {

View File

@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service';
@ -22,10 +23,11 @@ describe(APIKeyService.name, () => {
describe('create', () => {
it('should create a new key', async () => {
keyMock.create.mockResolvedValue(keyStub.admin);
await sut.create(authStub.admin, { name: 'Test Key' });
await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] });
expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'Test Key',
permissions: [Permission.ALL],
userId: authStub.admin.user.id,
});
expect(cryptoMock.newPassword).toHaveBeenCalled();
@ -35,11 +37,12 @@ describe(APIKeyService.name, () => {
it('should not require a name', async () => {
keyMock.create.mockResolvedValue(keyStub.admin);
await sut.create(authStub.admin, {});
await sut.create(authStub.admin, { permissions: [Permission.ALL] });
expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'API Key',
permissions: [Permission.ALL],
userId: authStub.admin.user.id,
});
expect(cryptoMock.newPassword).toHaveBeenCalled();

View File

@ -1,9 +1,10 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto } from 'src/dtos/api-key.dto';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { isGranted } from 'src/utils/access';
@Injectable()
export class APIKeyService {
@ -14,16 +15,22 @@ export class APIKeyService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.crypto.newPassword(32);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const entity = await this.repository.create({
key: this.crypto.hashSha256(secret),
name: dto.name || 'API Key',
userId: auth.user.id,
permissions: dto.permissions,
});
return { secret, apiKey: this.map(entity) };
}
async update(auth: AuthDto, id: string, dto: APIKeyCreateDto): Promise<APIKeyResponseDto> {
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
const exists = await this.repository.getById(auth.user.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
@ -62,6 +69,7 @@ export class APIKeyService {
name: entity.name,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
permissions: entity.permissions,
};
}
}

View File

@ -31,6 +31,7 @@ import {
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -38,6 +39,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
export interface LoginDetails {
@ -61,6 +63,7 @@ export type ValidateRequest = {
metadata: {
sharedLinkRoute: boolean;
adminRoute: boolean;
permission?: Permission;
uri: string;
};
};
@ -157,7 +160,7 @@ export class AuthService {
async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise<AuthDto> {
const authDto = await this.validate({ headers, queryParams });
const { adminRoute, sharedLinkRoute, uri } = metadata;
const { adminRoute, sharedLinkRoute, permission, uri } = metadata;
if (!authDto.user.isAdmin && adminRoute) {
this.logger.warn(`Denied access to admin only route: ${uri}`);
@ -169,6 +172,10 @@ export class AuthService {
throw new ForbiddenException('Forbidden');
}
if (authDto.apiKey && permission && !isGranted({ requested: [permission], current: authDto.apiKey.permissions })) {
throw new ForbiddenException(`Missing required permission: ${permission}`);
}
return authDto;
}

View File

@ -50,7 +50,7 @@ export class MemoryService {
}
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id);
const memory = await this.repository.update({
id,
@ -82,7 +82,7 @@ export class MemoryService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id);
const repos = { accessRepository: this.accessRepository, repository: this.repository };
const results = await removeAssets(auth, repos, {

View File

@ -113,7 +113,7 @@ export class PersonService {
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId);
const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
@ -142,7 +142,7 @@ export class PersonService {
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId);
await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id);
const face = await this.repository.getFaceById(dto.id);
@ -226,7 +226,7 @@ export class PersonService {
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id);
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// TODO: set by faceId directly
@ -581,7 +581,7 @@ export class PersonService {
throw new BadRequestException('Cannot merge a person into themselves');
}
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id);
let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id;

View File

@ -0,0 +1,15 @@
import { Permission } from 'src/enum';
import { setIsSuperset } from 'src/utils/set';
export type GrantedRequest = {
requested: Permission[];
current: Permission[];
};
export const isGranted = ({ requested, current }: GrantedRequest) => {
if (current.includes(Permission.ALL)) {
return true;
}
return setIsSuperset(new Set(current), new Set(requested));
};

View File

@ -1,25 +1,21 @@
<script lang="ts">
import type { ApiKeyResponseDto } from '@immich/sdk';
import { mdiKeyVariant } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { t } from 'svelte-i18n';
export let apiKey: Partial<ApiKeyResponseDto>;
export let apiKey: { name: string };
export let title: string;
export let cancelText = $t('cancel');
export let submitText = $t('save');
const dispatch = createEventDispatcher<{
cancel: void;
submit: Partial<ApiKeyResponseDto>;
}>();
const handleCancel = () => dispatch('cancel');
export let onSubmit: (apiKey: { name: string }) => void;
export let onCancel: () => void;
const handleSubmit = () => {
if (apiKey.name) {
dispatch('submit', apiKey);
onSubmit({ name: apiKey.name });
} else {
notificationController.show({
message: $t('api_key_empty'),
@ -29,7 +25,7 @@
};
</script>
<FullScreenModal {title} icon={mdiKeyVariant} onClose={handleCancel}>
<FullScreenModal {title} icon={mdiKeyVariant} onClose={() => onCancel()}>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">{$t('name')}</label>
@ -37,7 +33,7 @@
</div>
</form>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={handleCancel}>{cancelText}</Button>
<Button color="gray" fullwidth on:click={() => onCancel()}>{cancelText}</Button>
<Button type="submit" fullwidth form="api-key-form">{submitText}</Button>
</svelte:fragment>
</FullScreenModal>

View File

@ -1,6 +1,13 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { createApiKey, deleteApiKey, getApiKeys, updateApiKey, type ApiKeyResponseDto } from '@immich/sdk';
import {
createApiKey,
deleteApiKey,
getApiKeys,
Permission,
updateApiKey,
type ApiKeyResponseDto,
} from '@immich/sdk';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
@ -14,7 +21,7 @@
export let keys: ApiKeyResponseDto[];
let newKey: Partial<ApiKeyResponseDto> | null = null;
let newKey: { name: string } | null = null;
let editKey: ApiKeyResponseDto | null = null;
let secret = '';
@ -28,9 +35,14 @@
keys = await getApiKeys();
}
const handleCreate = async (detail: Partial<ApiKeyResponseDto>) => {
const handleCreate = async ({ name }: { name: string }) => {
try {
const data = await createApiKey({ apiKeyCreateDto: detail });
const data = await createApiKey({
apiKeyCreateDto: {
name,
permissions: [Permission.All],
},
});
secret = data.secret;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
@ -84,8 +96,8 @@
title={$t('new_api_key')}
submitText={$t('create')}
apiKey={newKey}
on:submit={({ detail }) => handleCreate(detail)}
on:cancel={() => (newKey = null)}
onSubmit={(key) => handleCreate(key)}
onCancel={() => (newKey = null)}
/>
{/if}
@ -98,8 +110,8 @@
title={$t('api_key')}
submitText={$t('save')}
apiKey={editKey}
on:submit={({ detail }) => handleUpdate(detail)}
on:cancel={() => (editKey = null)}
onSubmit={(key) => handleUpdate(key)}
onCancel={() => (editKey = null)}
/>
{/if}