mirror of
https://github.com/immich-app/immich.git
synced 2026-03-06 06:37:02 +00:00
Compare commits
7 Commits
feat/plugi
...
feat/stati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd39767db | ||
|
|
fc5fc58759 | ||
|
|
9bb2fc238a | ||
|
|
76f5036026 | ||
|
|
032de9ff2f | ||
|
|
c3a533ab40 | ||
|
|
dbd6dcb786 |
10
.github/workflows/close-duplicates.yml
vendored
10
.github/workflows/close-duplicates.yml
vendored
@@ -54,16 +54,10 @@ jobs:
|
||||
issues: write
|
||||
discussions: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Close issue
|
||||
if: ${{ github.event_name == 'issues' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.issue.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
@@ -89,7 +83,7 @@ jobs:
|
||||
- name: Close discussion
|
||||
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.discussion.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -182,7 +182,7 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs'
|
||||
run: 'mise run tf output -json'
|
||||
run: 'mise run tf output -- -json'
|
||||
|
||||
- name: Output Cleaning
|
||||
id: clean
|
||||
|
||||
2
.github/workflows/docs-destroy.yml
vendored
2
.github/workflows/docs-destroy.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs'
|
||||
run: 'mise run tf destroy -refresh=false'
|
||||
run: 'mise run tf destroy -- -refresh=false'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"exiftool-vendored": "^28.3.1",
|
||||
"exiftool-vendored": "^31.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"luxon": "^3.4.4",
|
||||
|
||||
@@ -119,5 +119,6 @@ export const deviceDto = {
|
||||
isPendingSyncReset: false,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
appVersion: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -474,6 +474,7 @@
|
||||
"app_bar_signout_dialog_title": "Sign out",
|
||||
"app_download_links": "App Download Links",
|
||||
"app_settings": "App Settings",
|
||||
"app_stores": "App Stores",
|
||||
"app_update_available": "App update is available",
|
||||
"appears_in": "Appears in",
|
||||
"apply_count": "Apply ({count, number})",
|
||||
@@ -745,6 +746,7 @@
|
||||
"create": "Create",
|
||||
"create_album": "Create album",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"create_api_key": "Create API key",
|
||||
"create_library": "Create Library",
|
||||
"create_link": "Create link",
|
||||
"create_link_to_share": "Create link to share",
|
||||
@@ -1351,7 +1353,7 @@
|
||||
"minutes": "Minutes",
|
||||
"missing": "Missing",
|
||||
"mobile_app": "Mobile App",
|
||||
"mobile_app_download_onboarding_note": "You can access these options again from the Utilities page.",
|
||||
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
||||
"model": "Model",
|
||||
"month": "Month",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
@@ -1433,7 +1435,7 @@
|
||||
"notifications_setting_description": "Manage notifications",
|
||||
"oauth": "OAuth",
|
||||
"obtainium_configurator": "Obtainium Configurator",
|
||||
"obtainium_configurator_instructions": "Please create an API key and select a variant to create your Obtainium configuration link.",
|
||||
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
|
||||
"official_immich_resources": "Official Immich Resources",
|
||||
"offline": "Offline",
|
||||
"offset": "Offset",
|
||||
@@ -1604,7 +1606,6 @@
|
||||
"read_changelog": "Read Changelog",
|
||||
"readonly_mode_disabled": "Read-only mode disabled",
|
||||
"readonly_mode_enabled": "Read-only mode enabled",
|
||||
"plugins": "Plugins",
|
||||
"ready_for_upload": "Ready for upload",
|
||||
"reassign": "Reassign",
|
||||
"reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[tools]
|
||||
node = "22.20.0"
|
||||
flutter = "3.35.5"
|
||||
pnpm = "10.18.3"
|
||||
terragrunt = "0.58.12"
|
||||
opentofu = "1.7.1"
|
||||
flutter = "3.35.6"
|
||||
pnpm = "10.18.1"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"flutter": "3.35.4"
|
||||
"flutter": "3.35.6"
|
||||
}
|
||||
2
mobile/.vscode/settings.json
vendored
2
mobile/.vscode/settings.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.6",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [120]
|
||||
|
||||
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@@ -282,6 +282,7 @@ Class | Method | HTTP request | Description
|
||||
*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} |
|
||||
*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} |
|
||||
*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences |
|
||||
*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions |
|
||||
*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics |
|
||||
*UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore |
|
||||
*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |
|
||||
|
||||
56
mobile/openapi/lib/api/users_admin_api.dart
generated
56
mobile/openapi/lib/api/users_admin_api.dart
generated
@@ -231,6 +231,62 @@ class UsersAdminApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint is an admin-only route, and requires the `adminSession.read` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> getUserSessionsAdminWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/users/{id}/sessions'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint is an admin-only route, and requires the `adminSession.read` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<List<SessionResponseDto>?> getUserSessionsAdmin(String id,) async {
|
||||
final response = await getUserSessionsAdminWithHttpInfo(id,);
|
||||
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) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<SessionResponseDto>') as List)
|
||||
.cast<SessionResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint is an admin-only route, and requires the `adminUser.read` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@@ -150,6 +150,7 @@ class Permission {
|
||||
static const adminUserPeriodRead = Permission._(r'adminUser.read');
|
||||
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
|
||||
static const adminUserPeriodDelete = Permission._(r'adminUser.delete');
|
||||
static const adminSessionPeriodRead = Permission._(r'adminSession.read');
|
||||
static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll');
|
||||
|
||||
/// List of all possible values in this [enum][Permission].
|
||||
@@ -281,6 +282,7 @@ class Permission {
|
||||
adminUserPeriodRead,
|
||||
adminUserPeriodUpdate,
|
||||
adminUserPeriodDelete,
|
||||
adminSessionPeriodRead,
|
||||
adminAuthPeriodUnlinkAll,
|
||||
];
|
||||
|
||||
@@ -447,6 +449,7 @@ class PermissionTypeTransformer {
|
||||
case r'adminUser.read': return Permission.adminUserPeriodRead;
|
||||
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
|
||||
case r'adminUser.delete': return Permission.adminUserPeriodDelete;
|
||||
case r'adminSession.read': return Permission.adminSessionPeriodRead;
|
||||
case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
||||
@@ -13,6 +13,7 @@ part of openapi.api;
|
||||
class SessionCreateResponseDto {
|
||||
/// Returns a new [SessionCreateResponseDto] instance.
|
||||
SessionCreateResponseDto({
|
||||
required this.appVersion,
|
||||
required this.createdAt,
|
||||
required this.current,
|
||||
required this.deviceOS,
|
||||
@@ -24,6 +25,8 @@ class SessionCreateResponseDto {
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String? appVersion;
|
||||
|
||||
String createdAt;
|
||||
|
||||
bool current;
|
||||
@@ -50,6 +53,7 @@ class SessionCreateResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SessionCreateResponseDto &&
|
||||
other.appVersion == appVersion &&
|
||||
other.createdAt == createdAt &&
|
||||
other.current == current &&
|
||||
other.deviceOS == deviceOS &&
|
||||
@@ -63,6 +67,7 @@ class SessionCreateResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(appVersion == null ? 0 : appVersion!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(current.hashCode) +
|
||||
(deviceOS.hashCode) +
|
||||
@@ -74,10 +79,15 @@ class SessionCreateResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]';
|
||||
String toString() => 'SessionCreateResponseDto[appVersion=$appVersion, createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.appVersion != null) {
|
||||
json[r'appVersion'] = this.appVersion;
|
||||
} else {
|
||||
// json[r'appVersion'] = null;
|
||||
}
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'current'] = this.current;
|
||||
json[r'deviceOS'] = this.deviceOS;
|
||||
@@ -103,6 +113,7 @@ class SessionCreateResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SessionCreateResponseDto(
|
||||
appVersion: mapValueOfType<String>(json, r'appVersion'),
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
current: mapValueOfType<bool>(json, r'current')!,
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||
@@ -159,6 +170,7 @@ class SessionCreateResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'appVersion',
|
||||
'createdAt',
|
||||
'current',
|
||||
'deviceOS',
|
||||
|
||||
14
mobile/openapi/lib/model/session_response_dto.dart
generated
14
mobile/openapi/lib/model/session_response_dto.dart
generated
@@ -13,6 +13,7 @@ part of openapi.api;
|
||||
class SessionResponseDto {
|
||||
/// Returns a new [SessionResponseDto] instance.
|
||||
SessionResponseDto({
|
||||
required this.appVersion,
|
||||
required this.createdAt,
|
||||
required this.current,
|
||||
required this.deviceOS,
|
||||
@@ -23,6 +24,8 @@ class SessionResponseDto {
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String? appVersion;
|
||||
|
||||
String createdAt;
|
||||
|
||||
bool current;
|
||||
@@ -47,6 +50,7 @@ class SessionResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SessionResponseDto &&
|
||||
other.appVersion == appVersion &&
|
||||
other.createdAt == createdAt &&
|
||||
other.current == current &&
|
||||
other.deviceOS == deviceOS &&
|
||||
@@ -59,6 +63,7 @@ class SessionResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(appVersion == null ? 0 : appVersion!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(current.hashCode) +
|
||||
(deviceOS.hashCode) +
|
||||
@@ -69,10 +74,15 @@ class SessionResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]';
|
||||
String toString() => 'SessionResponseDto[appVersion=$appVersion, createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.appVersion != null) {
|
||||
json[r'appVersion'] = this.appVersion;
|
||||
} else {
|
||||
// json[r'appVersion'] = null;
|
||||
}
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'current'] = this.current;
|
||||
json[r'deviceOS'] = this.deviceOS;
|
||||
@@ -97,6 +107,7 @@ class SessionResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SessionResponseDto(
|
||||
appVersion: mapValueOfType<String>(json, r'appVersion'),
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
current: mapValueOfType<bool>(json, r'current')!,
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||
@@ -152,6 +163,7 @@ class SessionResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'appVersion',
|
||||
'createdAt',
|
||||
'current',
|
||||
'deviceOS',
|
||||
|
||||
@@ -481,14 +481,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
easy_image_viewer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_image_viewer
|
||||
sha256: fb6cb123c3605552cc91150dcdb50ca977001dcddfb71d20caa0c5edc9a80947
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
easy_localization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1413,14 +1405,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.4"
|
||||
photo_manager_image_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: photo_manager_image_provider
|
||||
sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pigeon:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -2180,4 +2164,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.35.4"
|
||||
flutter: ">=3.35.6"
|
||||
|
||||
@@ -6,12 +6,9 @@ version: 2.1.0+3022
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.35.4
|
||||
flutter: 3.35.6
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
async: ^2.11.0
|
||||
auto_route: ^9.2.0
|
||||
background_downloader: ^9.2.5
|
||||
@@ -23,10 +20,15 @@ dependencies:
|
||||
crop_image: ^1.0.16
|
||||
crypto: ^3.0.6
|
||||
device_info_plus: ^12.0.0
|
||||
# DB
|
||||
drift: ^2.23.1
|
||||
drift_flutter: ^0.2.4
|
||||
dynamic_color: ^1.7.0
|
||||
easy_image_viewer: ^1.5.1
|
||||
easy_localization: ^3.0.7+1
|
||||
ffi: ^2.1.4
|
||||
file_picker: ^8.0.0+1
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_cache_manager: ^3.4.1
|
||||
flutter_displaymode: ^0.6.0
|
||||
flutter_hooks: ^0.21.2
|
||||
@@ -37,26 +39,39 @@ dependencies:
|
||||
flutter_web_auth_2: ^5.0.0-alpha.0
|
||||
fluttertoast: ^8.2.12
|
||||
geolocator: ^14.0.0
|
||||
hooks_riverpod: ^2.6.1
|
||||
home_widget: ^0.8.0
|
||||
hooks_riverpod: ^2.6.1
|
||||
http: ^1.3.0
|
||||
image_picker: ^1.1.2
|
||||
intl: ^0.20.0
|
||||
isar:
|
||||
git:
|
||||
url: https://github.com/immich-app/isar
|
||||
ref: 'bb1dca40fe87a001122e5d43bc6254718cb49f3a'
|
||||
path: packages/isar/
|
||||
isar_community_flutter_libs: 3.3.0-dev.3
|
||||
local_auth: ^2.3.0
|
||||
logging: ^1.3.0
|
||||
maplibre_gl: ^0.22.0
|
||||
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: '893894b'
|
||||
network_info_plus: ^6.1.3
|
||||
octo_image: ^2.1.0
|
||||
openapi:
|
||||
path: openapi
|
||||
package_info_plus: ^8.3.0
|
||||
path: ^1.9.1
|
||||
path_provider: ^2.1.5
|
||||
path_provider_foundation: ^2.4.1
|
||||
permission_handler: ^11.4.0
|
||||
photo_manager: ^3.6.4
|
||||
photo_manager_image_provider: ^2.2.0
|
||||
pinput: ^5.0.1
|
||||
punycode: ^1.0.0
|
||||
riverpod_annotation: ^2.6.1
|
||||
scroll_date_picker: ^3.8.0
|
||||
scrollable_positioned_list: ^0.3.8
|
||||
share_handler: ^0.0.22
|
||||
share_plus: ^10.1.4
|
||||
@@ -69,52 +84,34 @@ dependencies:
|
||||
uuid: ^4.5.1
|
||||
wakelock_plus: ^1.2.10
|
||||
worker_manager: ^7.2.3
|
||||
scroll_date_picker: ^3.8.0
|
||||
ffi: ^2.1.4
|
||||
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: '893894b'
|
||||
openapi:
|
||||
path: openapi
|
||||
isar:
|
||||
git:
|
||||
url: https://github.com/immich-app/isar
|
||||
ref: 'bb1dca40fe87a001122e5d43bc6254718cb49f3a'
|
||||
path: packages/isar/
|
||||
isar_community_flutter_libs: 3.3.0-dev.3
|
||||
# DB
|
||||
drift: ^2.23.1
|
||||
drift_flutter: ^0.2.4
|
||||
|
||||
dev_dependencies:
|
||||
auto_route_generator: ^9.0.0
|
||||
build_runner: ^2.4.8
|
||||
custom_lint: ^0.7.5
|
||||
# Drift generator
|
||||
drift_dev: ^2.23.1
|
||||
fake_async: ^1.3.1
|
||||
file: ^7.0.1 # for MemoryFileSystem
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_native_splash: ^2.4.5
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
build_runner: ^2.4.8
|
||||
auto_route_generator: ^9.0.0
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
flutter_native_splash: ^2.4.5
|
||||
immich_mobile_immich_lint:
|
||||
path: './immich_lint'
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
isar_generator:
|
||||
git:
|
||||
url: https://github.com/immich-app/isar
|
||||
ref: 'bb1dca40fe87a001122e5d43bc6254718cb49f3a'
|
||||
path: packages/isar_generator/
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
custom_lint: ^0.7.5
|
||||
riverpod_lint: ^2.6.1
|
||||
riverpod_generator: ^2.6.1
|
||||
mocktail: ^1.0.4
|
||||
immich_mobile_immich_lint:
|
||||
path: './immich_lint'
|
||||
fake_async: ^1.3.1
|
||||
file: ^7.0.1 # for MemoryFileSystem
|
||||
# Drift generator
|
||||
drift_dev: ^2.23.1
|
||||
# Type safe platform code
|
||||
pigeon: ^26.0.0
|
||||
riverpod_generator: ^2.6.1
|
||||
riverpod_lint: ^2.6.1
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -773,6 +773,54 @@
|
||||
"description": "This endpoint is an admin-only route, and requires the `adminUser.delete` permission."
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/sessions": {
|
||||
"get": {
|
||||
"operationId": "getUserSessionsAdmin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SessionResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Users (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-permission": "adminSession.read",
|
||||
"description": "This endpoint is an admin-only route, and requires the `adminSession.read` permission."
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/statistics": {
|
||||
"get": {
|
||||
"operationId": "getUserStatisticsAdmin",
|
||||
@@ -5717,154 +5765,6 @@
|
||||
"description": "This endpoint requires the `person.read` permission."
|
||||
}
|
||||
},
|
||||
"/plugins": {
|
||||
"get": {
|
||||
"operationId": "searchPlugins",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "isEnabled",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isInstalled",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isTrusted",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Plugin"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-permission": "plugin.read",
|
||||
"description": "This endpoint is an admin-only route, and requires the `plugin.read` permission."
|
||||
}
|
||||
},
|
||||
"/plugins/{id}": {
|
||||
"delete": {
|
||||
"operationId": "deletePlugin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Plugin"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updatePlugin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginUpdateDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Plugin"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-permission": "plugin.update",
|
||||
"description": "This endpoint is an admin-only route, and requires the `plugin.update` permission."
|
||||
}
|
||||
},
|
||||
"/search/cities": {
|
||||
"get": {
|
||||
"operationId": "getAssetsByCity",
|
||||
@@ -13359,9 +13259,6 @@
|
||||
"person.statistics",
|
||||
"person.merge",
|
||||
"person.reassign",
|
||||
"plugin.read",
|
||||
"plugin.update",
|
||||
"plugin.delete",
|
||||
"pinCode.create",
|
||||
"pinCode.update",
|
||||
"pinCode.delete",
|
||||
@@ -13418,6 +13315,7 @@
|
||||
"adminUser.read",
|
||||
"adminUser.update",
|
||||
"adminUser.delete",
|
||||
"adminSession.read",
|
||||
"adminAuth.unlinkAll"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -13649,66 +13547,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginResponseDto": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isInstalled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isTrusted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"packageId": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"createdAt",
|
||||
"description",
|
||||
"id",
|
||||
"isEnabled",
|
||||
"isInstalled",
|
||||
"isTrusted",
|
||||
"name",
|
||||
"packageId",
|
||||
"updatedAt",
|
||||
"version"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUpdateDto": {
|
||||
"properties": {
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"isEnabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PurchaseResponse": {
|
||||
"properties": {
|
||||
"hideBuyButtonUntil": {
|
||||
@@ -14514,6 +14352,10 @@
|
||||
},
|
||||
"SessionCreateResponseDto": {
|
||||
"properties": {
|
||||
"appVersion": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14543,6 +14385,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appVersion",
|
||||
"createdAt",
|
||||
"current",
|
||||
"deviceOS",
|
||||
@@ -14556,6 +14399,10 @@
|
||||
},
|
||||
"SessionResponseDto": {
|
||||
"properties": {
|
||||
"appVersion": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14582,6 +14429,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appVersion",
|
||||
"createdAt",
|
||||
"current",
|
||||
"deviceOS",
|
||||
|
||||
@@ -244,6 +244,17 @@ export type UserPreferencesUpdateDto = {
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
tags?: TagsUpdate;
|
||||
};
|
||||
export type SessionResponseDto = {
|
||||
appVersion: string | null;
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
expiresAt?: string;
|
||||
id: string;
|
||||
isPendingSyncReset: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type AssetStatsResponseDto = {
|
||||
images: number;
|
||||
total: number;
|
||||
@@ -1192,16 +1203,6 @@ export type ServerVersionHistoryResponseDto = {
|
||||
id: string;
|
||||
version: string;
|
||||
};
|
||||
export type SessionResponseDto = {
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
expiresAt?: string;
|
||||
id: string;
|
||||
isPendingSyncReset: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SessionCreateDto = {
|
||||
deviceOS?: string;
|
||||
deviceType?: string;
|
||||
@@ -1209,6 +1210,7 @@ export type SessionCreateDto = {
|
||||
duration?: number;
|
||||
};
|
||||
export type SessionCreateResponseDto = {
|
||||
appVersion: string | null;
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
deviceOS: string;
|
||||
@@ -1853,6 +1855,19 @@ export function restoreUserAdmin({ id }: {
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint is an admin-only route, and requires the `adminSession.read` permission.
|
||||
*/
|
||||
export function getUserSessionsAdmin({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: SessionResponseDto[];
|
||||
}>(`/admin/users/${encodeURIComponent(id)}/sessions`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint is an admin-only route, and requires the `adminUser.read` permission.
|
||||
*/
|
||||
@@ -4830,6 +4845,7 @@ export enum Permission {
|
||||
AdminUserRead = "adminUser.read",
|
||||
AdminUserUpdate = "adminUser.update",
|
||||
AdminUserDelete = "adminUser.delete",
|
||||
AdminSessionRead = "adminSession.read",
|
||||
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
|
||||
}
|
||||
export enum AssetMetadataKey {
|
||||
|
||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@@ -238,8 +238,8 @@ importers:
|
||||
specifier: ^60.0.0
|
||||
version: 60.0.0(eslint@9.37.0(jiti@2.6.1))
|
||||
exiftool-vendored:
|
||||
specifier: ^28.3.1
|
||||
version: 28.8.0
|
||||
specifier: ^31.1.0
|
||||
version: 31.1.0
|
||||
globals:
|
||||
specifier: ^16.0.0
|
||||
version: 16.4.0
|
||||
@@ -404,8 +404,8 @@ importers:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
exiftool-vendored:
|
||||
specifier: ^28.8.0
|
||||
version: 28.8.0
|
||||
specifier: ^31.1.0
|
||||
version: 31.1.0
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
@@ -789,6 +789,9 @@ importers:
|
||||
'@koddsson/eslint-plugin-tscompat':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@rollup/plugin-replace':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(rollup@4.52.5)
|
||||
'@socket.io/component-emitter':
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.2
|
||||
@@ -3495,8 +3498,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@photo-sphere-viewer/core': 5.14.0
|
||||
|
||||
'@photostructure/tz-lookup@11.2.0':
|
||||
resolution: {integrity: sha512-DwrvodcXHNSdGdeSF7SBL5o8aBlsaeuCuG7633F04nYsL3hn5Hxe3z/5kCqxv61J1q7ggKZ27GPylR3x0cPNXQ==}
|
||||
'@photostructure/tz-lookup@11.2.1':
|
||||
resolution: {integrity: sha512-ugPtvpdLwGQ8IWezSGFgUCYOpO/XXetfKLNv+UN2jjTYyfIDq9dA21GydGyzXuoQ06nN3VGBd3JxmEu+ZtXScg==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
@@ -3681,6 +3684,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
|
||||
'@rollup/plugin-replace@6.0.2':
|
||||
resolution: {integrity: sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@rollup/pluginutils@5.3.0':
|
||||
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5210,9 +5222,9 @@ packages:
|
||||
resolution: {integrity: sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==}
|
||||
hasBin: true
|
||||
|
||||
batch-cluster@13.0.0:
|
||||
resolution: {integrity: sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==}
|
||||
engines: {node: '>=14'}
|
||||
batch-cluster@15.0.1:
|
||||
resolution: {integrity: sha512-eUmh0ld1AUPKTEmdzwGF9QTSexXAyt9rA1F5zDfW1wUi3okA3Tal4NLdCeFI6aiKpBenQhR6NmK9bW9tBHTGPQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
batch@0.6.1:
|
||||
resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==}
|
||||
@@ -6547,16 +6559,18 @@ packages:
|
||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
exiftool-vendored.exe@13.0.0:
|
||||
resolution: {integrity: sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==}
|
||||
exiftool-vendored.exe@13.38.0:
|
||||
resolution: {integrity: sha512-oZx5enTAvSiIAXL+OEk7nNWrfUhEdKUpaGwDjCmz4VKwOa4HbisqyM808xPGPYj8X7XikcME/fq5hvevPeE3cw==}
|
||||
os: [win32]
|
||||
|
||||
exiftool-vendored.pl@13.0.1:
|
||||
resolution: {integrity: sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==}
|
||||
exiftool-vendored.pl@13.38.0:
|
||||
resolution: {integrity: sha512-Q3xl1nnwswrsR5344z4NyqvI74fKwla+VJHY1N+32gcDgt8cs9KBsDUwcNzKHSOSa/MjEfniuCJVrQiqR05iag==}
|
||||
os: ['!win32']
|
||||
hasBin: true
|
||||
|
||||
exiftool-vendored@28.8.0:
|
||||
resolution: {integrity: sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==}
|
||||
exiftool-vendored@31.1.0:
|
||||
resolution: {integrity: sha512-q8StxLawHLDvhqv/uoBYCfVbDskn49Cr5ouNCZhh4lgryGu1aymHwK9AvO6RcW2SbPm5MSnQDJOfGp2MW5Nnrw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
expect-type@1.2.1:
|
||||
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
|
||||
@@ -15128,7 +15142,7 @@ snapshots:
|
||||
'@photo-sphere-viewer/core': 5.14.0
|
||||
three: 0.180.0
|
||||
|
||||
'@photostructure/tz-lookup@11.2.0': {}
|
||||
'@photostructure/tz-lookup@11.2.1': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
@@ -15288,6 +15302,13 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
||||
'@rollup/plugin-replace@6.0.2(rollup@4.52.5)':
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
|
||||
magic-string: 0.30.19
|
||||
optionalDependencies:
|
||||
rollup: 4.52.5
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.52.5)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -17069,7 +17090,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.8.15: {}
|
||||
|
||||
batch-cluster@13.0.0: {}
|
||||
batch-cluster@15.0.1: {}
|
||||
|
||||
batch@0.6.1: {}
|
||||
|
||||
@@ -18608,21 +18629,21 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
exiftool-vendored.exe@13.0.0:
|
||||
exiftool-vendored.exe@13.38.0:
|
||||
optional: true
|
||||
|
||||
exiftool-vendored.pl@13.0.1: {}
|
||||
exiftool-vendored.pl@13.38.0: {}
|
||||
|
||||
exiftool-vendored@28.8.0:
|
||||
exiftool-vendored@31.1.0:
|
||||
dependencies:
|
||||
'@photostructure/tz-lookup': 11.2.0
|
||||
'@photostructure/tz-lookup': 11.2.1
|
||||
'@types/luxon': 3.7.1
|
||||
batch-cluster: 13.0.0
|
||||
exiftool-vendored.pl: 13.0.1
|
||||
batch-cluster: 15.0.1
|
||||
exiftool-vendored.pl: 13.38.0
|
||||
he: 1.2.0
|
||||
luxon: 3.7.2
|
||||
optionalDependencies:
|
||||
exiftool-vendored.exe: 13.0.0
|
||||
exiftool-vendored.exe: 13.38.0
|
||||
|
||||
expect-type@1.2.1: {}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
|
||||
# Flutter SDK
|
||||
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
||||
ENV FLUTTER_CHANNEL="stable"
|
||||
ENV FLUTTER_VERSION="3.35.4"
|
||||
ENV FLUTTER_VERSION="3.35.6"
|
||||
ENV FLUTTER_HOME=/flutter
|
||||
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.3.3",
|
||||
"exiftool-vendored": "^28.8.0",
|
||||
"exiftool-vendored": "^31.1.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
|
||||
@@ -18,7 +18,6 @@ import { NotificationController } from 'src/controllers/notification.controller'
|
||||
import { OAuthController } from 'src/controllers/oauth.controller';
|
||||
import { PartnerController } from 'src/controllers/partner.controller';
|
||||
import { PersonController } from 'src/controllers/person.controller';
|
||||
import { PluginController } from 'src/controllers/plugin.controller';
|
||||
import { SearchController } from 'src/controllers/search.controller';
|
||||
import { ServerController } from 'src/controllers/server.controller';
|
||||
import { SessionController } from 'src/controllers/session.controller';
|
||||
@@ -55,7 +54,6 @@ export const controllers = [
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PersonController,
|
||||
PluginController,
|
||||
SearchController,
|
||||
ServerController,
|
||||
SessionController,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { PluginResponseDto, PluginSearchDto, PluginUpdateDto } from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Plugin')
|
||||
@Controller('plugins')
|
||||
export class PluginController {
|
||||
constructor(private service: PluginService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ admin: true, permission: Permission.PluginRead })
|
||||
searchPlugins(@Auth() auth: AuthDto, @Query() dto: PluginSearchDto): Promise<PluginResponseDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ admin: true, permission: Permission.PluginUpdate })
|
||||
updatePlugin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: PluginUpdateDto,
|
||||
): Promise<PluginResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put,
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
UserAdminCreateDto,
|
||||
@@ -58,6 +59,12 @@ export class UserAdminController {
|
||||
return this.service.delete(auth, id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/sessions')
|
||||
@Authenticated({ permission: Permission.AdminSessionRead, admin: true })
|
||||
getUserSessionsAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<SessionResponseDto[]> {
|
||||
return this.service.getSessions(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/statistics')
|
||||
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
|
||||
getUserStatisticsAdmin(
|
||||
|
||||
@@ -144,19 +144,6 @@ export type UserAdmin = User & {
|
||||
metadata: UserMetadataItem[];
|
||||
};
|
||||
|
||||
export type Plugin = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
packageId: string;
|
||||
version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
isEnabled: boolean;
|
||||
isInstalled: boolean;
|
||||
isTrusted: boolean;
|
||||
};
|
||||
|
||||
export type StorageAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@@ -251,6 +238,7 @@ export type Session = {
|
||||
expiresAt: Date | null;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
appVersion: string | null;
|
||||
pinExpiresAt: Date | null;
|
||||
isPendingSyncReset: boolean;
|
||||
};
|
||||
@@ -321,7 +309,7 @@ export const columns = {
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
authSharedLink: [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
|
||||
@@ -195,4 +195,10 @@ export class EnvDto {
|
||||
@IsString()
|
||||
@Optional()
|
||||
REDIS_URL?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_DEV_CORS_ALL_ORIGINS?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_DEV_CORS_CREDENTIALS?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
import { Plugin } from 'src/database';
|
||||
import { Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class PluginSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
isEnabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isTrusted?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isInstalled?: boolean;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class PluginImportDto {
|
||||
url!: string;
|
||||
install!: boolean;
|
||||
isEnabled!: boolean;
|
||||
isTrusted!: boolean;
|
||||
}
|
||||
|
||||
export class PluginUpdateDto {
|
||||
@IsBoolean()
|
||||
isEnabled!: boolean;
|
||||
}
|
||||
|
||||
export class PluginResponseDto {
|
||||
id!: string;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
packageId!: string;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
version!: number;
|
||||
name!: string;
|
||||
description!: string;
|
||||
isEnabled!: boolean;
|
||||
isInstalled!: boolean;
|
||||
isTrusted!: boolean;
|
||||
}
|
||||
|
||||
export const mapPlugin = (plugin: Plugin): PluginResponseDto => ({
|
||||
id: plugin.id,
|
||||
createdAt: plugin.createdAt,
|
||||
updatedAt: plugin.updatedAt,
|
||||
packageId: plugin.packageId,
|
||||
version: plugin.version,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
isEnabled: plugin.isEnabled,
|
||||
isInstalled: plugin.isInstalled,
|
||||
isTrusted: plugin.isTrusted,
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export class SessionResponseDto {
|
||||
current!: boolean;
|
||||
deviceType!: string;
|
||||
deviceOS!: string;
|
||||
appVersion!: string | null;
|
||||
isPendingSyncReset!: boolean;
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
expiresAt: entity.expiresAt?.toISOString(),
|
||||
current: currentId === entity.id,
|
||||
appVersion: entity.appVersion,
|
||||
deviceOS: entity.deviceOS,
|
||||
deviceType: entity.deviceType,
|
||||
isPendingSyncReset: entity.isPendingSyncReset,
|
||||
|
||||
@@ -173,6 +173,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
||||
const license = metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
||||
)?.value;
|
||||
|
||||
return {
|
||||
...mapUser(entity),
|
||||
storageLabel: entity.storageLabel,
|
||||
|
||||
@@ -164,10 +164,6 @@ export enum Permission {
|
||||
PersonMerge = 'person.merge',
|
||||
PersonReassign = 'person.reassign',
|
||||
|
||||
PluginRead = 'plugin.read',
|
||||
PluginUpdate = 'plugin.update',
|
||||
PluginDelete = 'plugin.delete',
|
||||
|
||||
PinCodeCreate = 'pinCode.create',
|
||||
PinCodeUpdate = 'pinCode.update',
|
||||
PinCodeDelete = 'pinCode.delete',
|
||||
@@ -240,6 +236,8 @@ export enum Permission {
|
||||
AdminUserUpdate = 'adminUser.update',
|
||||
AdminUserDelete = 'adminUser.delete',
|
||||
|
||||
AdminSessionRead = 'adminSession.read',
|
||||
|
||||
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
export type PluginFactory = {
|
||||
register: () => MaybePromise<Plugin>;
|
||||
};
|
||||
|
||||
export type PluginLike = Plugin | PluginFactory | { default: Plugin } | { plugin: Plugin };
|
||||
|
||||
export interface Plugin<T extends PluginConfig | undefined = undefined> {
|
||||
version: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
actions: PluginAction<T>[];
|
||||
}
|
||||
|
||||
export type PluginAction<T extends PluginConfig | undefined = undefined> = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
events?: EventType[];
|
||||
config?: T;
|
||||
} & (
|
||||
| { type: ActionType.ASSET; onAction: OnAction<T, AssetDto> }
|
||||
| { type: ActionType.ALBUM; onAction: OnAction<T, AlbumDto> }
|
||||
| { type: ActionType.ALBUM_ASSET; onAction: OnAction<T, { asset: AssetDto; album: AlbumDto }> }
|
||||
);
|
||||
|
||||
export type OnAction<T extends PluginConfig | undefined, D = PluginActionData> = T extends undefined
|
||||
? (ctx: PluginContext, data: D) => MaybePromise<void>
|
||||
: (ctx: PluginContext, data: D, config: InferConfig<T>) => MaybePromise<void>;
|
||||
|
||||
export interface PluginContext {
|
||||
updateAsset: (asset: { id: string; isArchived: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginActionData = { data: { asset?: AssetDto; album?: AlbumDto } } & (
|
||||
| { type: EventType.ASSET_UPLOAD; data: { asset: AssetDto } }
|
||||
| { type: EventType.ASSET_UPDATE; data: { asset: AssetDto } }
|
||||
| { type: EventType.ASSET_TRASH; data: { asset: AssetDto } }
|
||||
| { type: EventType.ASSET_DELETE; data: { asset: AssetDto } }
|
||||
| { type: EventType.ALBUM_CREATE; data: { album: AlbumDto } }
|
||||
| { type: EventType.ALBUM_UPDATE; data: { album: AlbumDto } }
|
||||
);
|
||||
|
||||
export type PluginConfig = Record<string, ConfigItem>;
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
} & { [K in Types]: { type: K; default?: InferType<K> } }[Types];
|
||||
|
||||
export type InferType<T extends Types> = T extends 'string'
|
||||
? string
|
||||
: T extends 'date'
|
||||
? Date
|
||||
: T extends 'number'
|
||||
? number
|
||||
: T extends 'boolean'
|
||||
? boolean
|
||||
: never;
|
||||
|
||||
type Types = 'string' | 'boolean' | 'number' | 'date';
|
||||
type MaybePromise<T = void> = Promise<T> | T;
|
||||
type IfRequired<T extends ConfigItem, Type> = T['required'] extends true ? Type : Type | undefined;
|
||||
type InferConfig<T> = T extends PluginConfig
|
||||
? {
|
||||
[K in keyof T]: IfRequired<T[K], InferType<T[K]['type']>>;
|
||||
}
|
||||
: never;
|
||||
|
||||
export enum ActionType {
|
||||
ASSET = 'asset',
|
||||
ALBUM = 'album',
|
||||
ALBUM_ASSET = 'album-asset',
|
||||
}
|
||||
|
||||
export enum EventType {
|
||||
ASSET_UPLOAD = 'asset.upload',
|
||||
ASSET_UPDATE = 'asset.update',
|
||||
ASSET_TRASH = 'asset.trash',
|
||||
ASSET_DELETE = 'asset.delete',
|
||||
ASSET_ARCHIVE = 'asset.archive',
|
||||
ASSET_UNARCHIVE = 'asset.unarchive',
|
||||
|
||||
ALBUM_CREATE = 'album.create',
|
||||
ALBUM_UPDATE = 'album.update',
|
||||
ALBUM_DELETE = 'album.delete',
|
||||
}
|
||||
|
||||
export type AssetDto = { id: string; type: 'asset' };
|
||||
export type AlbumDto = { id: string; type: 'album' };
|
||||
@@ -13,7 +13,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiCustomExtension, ImmichQuery, MetadataKey, Permission } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AuthService, LoginDetails } from 'src/services/auth.service';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { getUserAgentDetails } from 'src/utils/request';
|
||||
|
||||
type AdminRoute = { admin?: true };
|
||||
type SharedLinkRoute = { sharedLink?: true };
|
||||
@@ -56,13 +56,14 @@ export const FileResponse = () =>
|
||||
|
||||
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userAgent = UAParser(request.headers['user-agent']);
|
||||
const { deviceType, deviceOS, appVersion } = getUserAgentDetails(request.headers);
|
||||
|
||||
return {
|
||||
clientIp: request.ip ?? '',
|
||||
isSecure: request.secure,
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
|
||||
deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
|
||||
deviceType,
|
||||
deviceOS,
|
||||
appVersion,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -86,7 +87,6 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler()];
|
||||
|
||||
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
||||
if (!options) {
|
||||
return true;
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import sdk from '../../open-api/typescript-sdk';
|
||||
|
||||
export type Plugin = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
filters: Filter[];
|
||||
// actions: Action[];
|
||||
};
|
||||
|
||||
export enum EntityType {
|
||||
Asset = 'asset',
|
||||
Album = 'album',
|
||||
}
|
||||
|
||||
type PluginItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: EntityType;
|
||||
configuration?: Config[];
|
||||
};
|
||||
|
||||
type FilterContext<C = Record<string, any>, D = any> = {
|
||||
api: {
|
||||
getAssetAlbums: (assetId: string) => Promise<any[]>;
|
||||
};
|
||||
sdk: typeof sdk;
|
||||
config: C;
|
||||
};
|
||||
|
||||
type AssetFilter = {
|
||||
type: EntityType.Asset;
|
||||
filter: (ctx: FilterContext, input: { asset: { id: string } }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type AlbumFilter = {
|
||||
type: EntityType.Album;
|
||||
filter: (ctx: FilterContext, input: { album: { id: string; name: string } }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export type Filter = PluginItem & (AssetFilter | AlbumFilter);
|
||||
|
||||
export type Config = {
|
||||
key: string;
|
||||
type: PluginConfigType;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PluginConfigType = 'string' | 'number' | 'boolean' | 'date' | 'albumId' | 'assetId';
|
||||
|
||||
const authenticate = (ctx: FilterContext) => {
|
||||
const
|
||||
sdk.init()
|
||||
|
||||
}
|
||||
|
||||
export const corePlugin: Plugin = {
|
||||
id: 'immich',
|
||||
name: 'Immich Core Plugin',
|
||||
description: 'Core actions and filters for workflows',
|
||||
filters: [
|
||||
{
|
||||
id: 'core.notInAnyAlbum',
|
||||
name: 'Not in any album',
|
||||
description: 'Filters assets that are not in any album',
|
||||
type: EntityType.Asset,
|
||||
async filter(ctx, { asset }) {
|
||||
const albums = await ctx.sdk.getAllAlbums({ assetId: asset.id });
|
||||
return albums.length === 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.notInAlbum',
|
||||
name: 'Not in an album',
|
||||
description: 'Run on assets not in the specified album',
|
||||
type: EntityType.Asset,
|
||||
configuration: [
|
||||
{
|
||||
key: 'albumId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async filter(ctx, { asset }) {
|
||||
// missing api to check if an asset is in an album
|
||||
const albums = await ctx.sdk.getAllAlbums({ assetId: asset.id });
|
||||
return !!albums.find((album) => album.id === ctx.config.albumId);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ActionType, AssetDto, Plugin, PluginContext } from 'src/interfaces/plugin.interface';
|
||||
|
||||
const onAsset = async (ctx: PluginContext, asset: AssetDto) => {
|
||||
await ctx.updateAsset({ id: asset.id, isArchived: true });
|
||||
};
|
||||
|
||||
export const plugin: Plugin = {
|
||||
version: 1,
|
||||
id: 'immich-plugins',
|
||||
name: 'Asset Plugin',
|
||||
description: 'Immich asset plugin',
|
||||
actions: [
|
||||
{
|
||||
id: 'asset.favorite',
|
||||
name: '',
|
||||
type: ActionType.ASSET,
|
||||
description: '',
|
||||
onAction: async (ctx, asset) => {
|
||||
await ctx.updateAsset({ id: asset.id, isArchived: false });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'asset.unfavorite',
|
||||
name: '',
|
||||
type: ActionType.ASSET,
|
||||
description: '',
|
||||
onAction: () => {
|
||||
console.log('Unfavorite');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'asset.action',
|
||||
name: '',
|
||||
type: ActionType.ASSET,
|
||||
description: '',
|
||||
onAction: (ctx, asset) => onAsset(ctx, asset),
|
||||
},
|
||||
{
|
||||
id: 'album-asset.action',
|
||||
name: '',
|
||||
type: ActionType.ALBUM_ASSET,
|
||||
description: '',
|
||||
onAction: (ctx, { asset }) => onAsset(ctx, asset),
|
||||
},
|
||||
{
|
||||
id: 'asset.unarchive',
|
||||
name: '',
|
||||
type: ActionType.ASSET,
|
||||
description: '',
|
||||
onAction: () => {
|
||||
console.log('Unarchive');
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -23,6 +23,7 @@ select
|
||||
"session"."id",
|
||||
"session"."updatedAt",
|
||||
"session"."pinExpiresAt",
|
||||
"session"."appVersion",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RegisterQueueOptions } from '@nestjs/bullmq';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
|
||||
import { QueueOptions } from 'bullmq';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
@@ -30,6 +31,13 @@ export interface EnvData {
|
||||
configFile?: string;
|
||||
logLevel?: LogLevel;
|
||||
|
||||
dev: {
|
||||
cors: {
|
||||
allOrigins?: boolean;
|
||||
credentials?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
buildMetadata: {
|
||||
build?: string;
|
||||
buildUrl?: string;
|
||||
@@ -222,6 +230,13 @@ const getEnv = (): EnvData => {
|
||||
configFile: dto.IMMICH_CONFIG_FILE,
|
||||
logLevel: dto.IMMICH_LOG_LEVEL,
|
||||
|
||||
dev: {
|
||||
cors: {
|
||||
allOrigins: dto.IMMICH_DEV_CORS_ALL_ORIGINS,
|
||||
credentials: dto.IMMICH_DEV_CORS_CREDENTIALS,
|
||||
},
|
||||
},
|
||||
|
||||
buildMetadata: {
|
||||
build: dto.IMMICH_BUILD,
|
||||
buildUrl: dto.IMMICH_BUILD_URL,
|
||||
@@ -342,6 +357,24 @@ export class ConfigRepository {
|
||||
return this.getEnv().environment === ImmichEnvironment.Development;
|
||||
}
|
||||
|
||||
getCorsOptions(): CorsOptions | undefined {
|
||||
const options: Partial<CorsOptions> = {};
|
||||
const env = this.getEnv();
|
||||
|
||||
if (env.dev.cors.allOrigins) {
|
||||
options.origin = (requestOrigin, callback) => {
|
||||
callback(null, requestOrigin);
|
||||
};
|
||||
}
|
||||
if (env.dev.cors.credentials) {
|
||||
options.credentials = env.dev.cors.credentials;
|
||||
}
|
||||
if (Object.keys(options).length > 0) {
|
||||
return options;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getWorker() {
|
||||
return this.worker;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { NotificationRepository } from 'src/repositories/notification.repository
|
||||
import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
@@ -45,6 +44,7 @@ import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
|
||||
export const repositories = [
|
||||
AccessRepository,
|
||||
ActivityRepository,
|
||||
@@ -76,7 +76,6 @@ export const repositories = [
|
||||
PartnerRepository,
|
||||
PersonRepository,
|
||||
ProcessRepository,
|
||||
PluginRepository,
|
||||
SearchRepository,
|
||||
SessionRepository,
|
||||
ServerInfoRepository,
|
||||
|
||||
@@ -84,6 +84,7 @@ export class MetadataRepository {
|
||||
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'],
|
||||
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
|
||||
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
|
||||
geolocation: true,
|
||||
// Enable exiftool LFS to parse metadata for files larger than 2GB.
|
||||
readArgs: ['-api', 'largefilesupport=1'],
|
||||
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { PluginLike } from 'src/interfaces/plugin.interface';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
|
||||
type PluginSearchOptions = {
|
||||
id?: string;
|
||||
namespace?: string;
|
||||
version?: number;
|
||||
name?: string;
|
||||
isEnabled?: boolean;
|
||||
isInstalled?: boolean;
|
||||
isTrusted?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PluginRepository {
|
||||
constructor(
|
||||
@InjectKysely() private db: Kysely<DB>,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(PluginRepository.name);
|
||||
}
|
||||
|
||||
search(options: PluginSearchOptions) {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select([
|
||||
'id',
|
||||
'packageId',
|
||||
'version',
|
||||
'name',
|
||||
'description',
|
||||
'isEnabled',
|
||||
'isInstalled',
|
||||
'isTrusted',
|
||||
'requirePath',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.$if(!!options.id, (qb) => qb.where('id', '=', options.id!))
|
||||
.$if(!!options.version, (qb) => qb.where('version', '=', options.version!))
|
||||
.$if(!!options.name, (qb) => qb.where('name', '=', options.name!))
|
||||
.$if(!!options.isEnabled, (qb) => qb.where('isEnabled', '=', options.isEnabled!))
|
||||
.$if(!!options.isInstalled, (qb) => qb.where('isInstalled', '=', options.isInstalled!))
|
||||
.$if(!!options.isTrusted, (qb) => qb.where('isTrusted', '=', options.isTrusted!))
|
||||
.execute();
|
||||
}
|
||||
|
||||
create(dto: Insertable<PluginTable>) {
|
||||
return this.db.insertInto('plugin').values(dto).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.db.selectFrom('plugin').where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
update(dto: Updateable<PluginTable>) {
|
||||
return this.db.updateTable('plugin').set(dto).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('plugin').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
async download(url: string, downloadPath: string): Promise<void> {
|
||||
try {
|
||||
const { json } = await fetch(url);
|
||||
await writeFile(downloadPath, await json());
|
||||
} catch (error) {
|
||||
this.logger.error(`Error downloading the plugin from ${url}. ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
load(pluginPath: string): Promise<PluginLike> {
|
||||
return import(pluginPath);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
@@ -106,7 +105,6 @@ export class ImmichDatabase {
|
||||
PartnerTable,
|
||||
PersonTable,
|
||||
PersonAuditTable,
|
||||
PluginTable,
|
||||
SessionTable,
|
||||
SharedLinkAssetTable,
|
||||
SharedLinkTable,
|
||||
@@ -204,8 +202,6 @@ export interface DB {
|
||||
person: PersonTable;
|
||||
person_audit: PersonAuditTable;
|
||||
|
||||
plugin: PluginTable;
|
||||
|
||||
session: SessionTable;
|
||||
session_sync_checkpoint: SessionSyncCheckpointTable;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" ADD "appVersion" character varying;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Insertable } from 'kysely';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
|
||||
const plugin: Insertable<PluginTable> = {
|
||||
version: 1,
|
||||
id: '123',
|
||||
name: 'Immich Core Plugin',
|
||||
description: 'Core plugins for Immich',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
packageId: 'immich-plugin-',
|
||||
};
|
||||
|
||||
@Table('plugins')
|
||||
export class PluginTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Date | null;
|
||||
|
||||
@Column({ unique: true })
|
||||
packageId!: string;
|
||||
|
||||
@Column()
|
||||
version!: number;
|
||||
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isEnabled!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isInstalled!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isTrusted!: Generated<boolean>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
requirePath!: string | null;
|
||||
}
|
||||
@@ -42,6 +42,9 @@ export class SessionTable {
|
||||
@Column({ default: '' })
|
||||
deviceOS!: Generated<string>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
appVersion!: string | null;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const loginDetails = {
|
||||
clientIp: '127.0.0.1',
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
const fixtures = {
|
||||
@@ -243,6 +244,7 @@ describe(AuthService.name, () => {
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -408,6 +410,7 @@ describe(AuthService.name, () => {
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -435,6 +438,7 @@ describe(AuthService.name, () => {
|
||||
user: factory.authUser(),
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -456,6 +460,7 @@ describe(AuthService.name, () => {
|
||||
user: factory.authUser(),
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
|
||||
@@ -29,11 +29,13 @@ import { BaseService } from 'src/services/base.service';
|
||||
import { isGranted } from 'src/utils/access';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { getUserAgentDetails } from 'src/utils/request';
|
||||
export interface LoginDetails {
|
||||
isSecure: boolean;
|
||||
clientIp: string;
|
||||
deviceType: string;
|
||||
deviceOS: string;
|
||||
appVersion: string | null;
|
||||
}
|
||||
|
||||
interface ClaimOptions<T> {
|
||||
@@ -218,7 +220,7 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return this.validateSession(session);
|
||||
return this.validateSession(session, headers);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
@@ -463,15 +465,22 @@ export class AuthService extends BaseService {
|
||||
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
|
||||
}
|
||||
|
||||
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
||||
private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise<AuthDto> {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
const session = await this.sessionRepository.getByToken(hashedToken);
|
||||
if (session?.user) {
|
||||
const { appVersion, deviceOS, deviceType } = getUserAgentDetails(headers);
|
||||
const now = DateTime.now();
|
||||
const updatedAt = DateTime.fromJSDate(session.updatedAt);
|
||||
const diff = now.diff(updatedAt, ['hours']);
|
||||
if (diff.hours > 1) {
|
||||
await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
|
||||
if (diff.hours > 1 || appVersion != session.appVersion) {
|
||||
await this.sessionRepository.update(session.id, {
|
||||
id: session.id,
|
||||
updatedAt: new Date(),
|
||||
appVersion,
|
||||
deviceOS,
|
||||
deviceType,
|
||||
});
|
||||
}
|
||||
|
||||
// Pin check
|
||||
@@ -529,6 +538,7 @@ export class AuthService extends BaseService {
|
||||
token: tokenHashed,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
appVersion: loginDetails.appVersion,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ import { NotificationRepository } from 'src/repositories/notification.repository
|
||||
import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
@@ -139,7 +138,6 @@ export class BaseService {
|
||||
protected oauthRepository: OAuthRepository,
|
||||
protected partnerRepository: PartnerRepository,
|
||||
protected personRepository: PersonRepository,
|
||||
protected pluginRepository: PluginRepository,
|
||||
protected processRepository: ProcessRepository,
|
||||
protected searchRepository: SearchRepository,
|
||||
protected serverInfoRepository: ServerInfoRepository,
|
||||
|
||||
@@ -22,7 +22,6 @@ import { NotificationAdminService } from 'src/services/notification-admin.servic
|
||||
import { NotificationService } from 'src/services/notification.service';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { ServerService } from 'src/services/server.service';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
@@ -41,7 +40,6 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
|
||||
export const services = [
|
||||
ApiKeyService,
|
||||
@@ -68,7 +66,6 @@ export const services = [
|
||||
NotificationAdminService,
|
||||
PartnerService,
|
||||
PersonService,
|
||||
PluginService,
|
||||
SearchService,
|
||||
ServerService,
|
||||
SessionService,
|
||||
@@ -87,5 +84,4 @@ export const services = [
|
||||
UserService,
|
||||
VersionService,
|
||||
ViewService,
|
||||
WorkflowService,
|
||||
];
|
||||
|
||||
@@ -447,7 +447,10 @@ export class MetadataService extends BaseService {
|
||||
* For RAW images in the CR2 or RAF format, the "ImageSize" value seems to be correct,
|
||||
* but ImageWidth and ImageHeight are not correct (they contain the dimensions of the preview image).
|
||||
*/
|
||||
let [width, height] = exifTags.ImageSize?.split('x').map((dim) => Number.parseInt(dim) || undefined) || [];
|
||||
let [width, height] =
|
||||
exifTags.ImageSize?.toString()
|
||||
?.split('x')
|
||||
?.map((dim) => Number.parseInt(dim) || undefined) ?? [];
|
||||
if (!width || !height) {
|
||||
[width, height] = [exifTags.ImageWidth, exifTags.ImageHeight];
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Plugin } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { mapPlugin, PluginSearchDto, PluginUpdateDto } from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
id: '123',
|
||||
name: 'Immich Core Plugin',
|
||||
description: 'Core plugins for Immich',
|
||||
version: 1,
|
||||
isEnabled: true,
|
||||
isInstalled: true,
|
||||
isTrusted: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
packageId: 'immich-plugin-',
|
||||
},
|
||||
];
|
||||
|
||||
export class PluginService extends BaseService {
|
||||
async search(auth: AuthDto, dto: PluginSearchDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.PluginRead, ids: [] });
|
||||
// return this.pluginRepository.search(dto);
|
||||
|
||||
return plugins.map(mapPlugin);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PluginUpdateDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.PluginUpdate, ids: [id] });
|
||||
return this.pluginRepository.update({
|
||||
id,
|
||||
isEnabled: dto.isEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string) {
|
||||
await this.requireAccess({ auth, permission: Permission.PluginUpdate, ids: [id] });
|
||||
await this.pluginRepository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
UserAdminCreateDto,
|
||||
@@ -119,6 +120,11 @@ export class UserAdminService extends BaseService {
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async getSessions(auth: AuthDto, id: string): Promise<SessionResponseDto[]> {
|
||||
const sessions = await this.sessionRepository.getByUserId(id);
|
||||
return sessions.map((session) => mapSession(session));
|
||||
}
|
||||
|
||||
async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
|
||||
const stats = await this.assetRepository.getStatistics(id, dto);
|
||||
return mapStats(stats);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PluginLike } from 'src/interfaces/plugin.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowService extends BaseService {
|
||||
private plugins?: PluginLike[];
|
||||
|
||||
async init(): Promise<void> {
|
||||
const activePlugins = await this.pluginRepository.search({ isEnabled: true });
|
||||
const installPaths = activePlugins.map((p) => p.requirePath).filter(Boolean) as string[];
|
||||
this.plugins = await Promise.all(installPaths.map((path) => this.pluginRepository.load(path!)));
|
||||
}
|
||||
|
||||
// async register() {
|
||||
// const plugins = ['/src/abc'];
|
||||
// for (const pluginModule of plugins) {
|
||||
// // eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
// try {
|
||||
// const plugin: Plugin = ;
|
||||
// const actions = await plugin.register();
|
||||
// for (const action of actions) {
|
||||
// this.actions[action.id] = action;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error(`Unable to load module: ${pluginModule}`, error);
|
||||
// continue;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AssetDto, EventType, OnAction, PluginConfig } from 'src/interfaces/plugin.interface';
|
||||
|
||||
export const createPluginAction = <T extends PluginConfig | undefined = undefined>(options: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
events?: EventType[];
|
||||
config?: T;
|
||||
}) => ({
|
||||
addHandler: (onAction: OnAction<T>) => ({ ...options, onAction }),
|
||||
onAsset: (onAction: OnAction<T, AssetDto>) => ({ ...options, onAction }),
|
||||
});
|
||||
@@ -1,5 +1,22 @@
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
export const fromChecksum = (checksum: string): Buffer => {
|
||||
return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
|
||||
};
|
||||
|
||||
export const fromMaybeArray = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);
|
||||
|
||||
const getAppVersionFromUA = (ua: string) =>
|
||||
ua.match(/^Immich_(?:Android|iOS)_(?<appVersion>.+)$/)?.groups?.appVersion ?? null;
|
||||
|
||||
export const getUserAgentDetails = (headers: IncomingHttpHeaders) => {
|
||||
const userAgent = UAParser(headers['user-agent']);
|
||||
const appVersion = getAppVersionFromUA(headers['user-agent'] ?? '');
|
||||
|
||||
return {
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || (headers['devicemodel'] as string) || '',
|
||||
deviceOS: userAgent.os.name || (headers['devicetype'] as string) || '',
|
||||
appVersion,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,7 +34,17 @@ async function bootstrap() {
|
||||
app.use(cookieParser());
|
||||
app.use(json({ limit: '10mb' }));
|
||||
if (configRepository.isDev()) {
|
||||
app.enableCors();
|
||||
const options = configRepository.getCorsOptions();
|
||||
if (options) {
|
||||
logger.warn(`Enabling CORS: ${JSON.stringify(configRepository.getEnv().dev.cors)}`);
|
||||
logger.warn(
|
||||
'NOTE: to properly support a fully statically hosted frontend you MUST configure the frontend/backend to be on the same site. i.e. frontend=https://localhost:1234 and backend=http://localhost:2283 or configure TLS',
|
||||
);
|
||||
app.enableCors(options);
|
||||
} else {
|
||||
logger.warn('Enabling CORS');
|
||||
app.enableCors();
|
||||
}
|
||||
}
|
||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||
useSwagger(app, { write: configRepository.isDev() });
|
||||
|
||||
@@ -628,7 +628,7 @@ const syncStream = () => {
|
||||
};
|
||||
|
||||
const loginDetails = () => {
|
||||
return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' };
|
||||
return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '', appVersion: null };
|
||||
};
|
||||
|
||||
const loginResponse = (): LoginResponseDto => {
|
||||
|
||||
@@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
||||
userId: newUuid(),
|
||||
pinExpiresAt: newDate(),
|
||||
isPendingSyncReset: false,
|
||||
appVersion: session.appVersion ?? null,
|
||||
...session,
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.8.0",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<AppShellHeader>
|
||||
<NavigationBar showUploadButton={false} noBorder />
|
||||
</AppShellHeader>
|
||||
<AppShellSidebar bind:open={sidebarStore.isOpen}>
|
||||
<AppShellSidebar bind:open={sidebarStore.isOpen} class="border-none shadow-none">
|
||||
<AdminSidebar />
|
||||
</AppShellSidebar>
|
||||
|
||||
|
||||
@@ -6,22 +6,26 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<HStack wrap>
|
||||
<p>{$t('mobile_app_download_onboarding_note')}</p>
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
size="large"
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
fullWidth
|
||||
onclick={() => modalManager.show(AppDownloadModal, {})}
|
||||
leadingIcon={mdiCellphoneArrowDownVariant}
|
||||
>
|
||||
{$t('app_stores')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
fullWidth
|
||||
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
|
||||
leadingIcon={mdiLinkEdit}
|
||||
>
|
||||
{$t('obtainium_configurator')}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
shape="semi-round"
|
||||
onclick={() => modalManager.show(AppDownloadModal, {})}
|
||||
leadingIcon={mdiCellphoneArrowDownVariant}
|
||||
>
|
||||
{$t('app_download_links')}
|
||||
</Button>
|
||||
</HStack>
|
||||
<p>{$t('mobile_app_download_onboarding_note')}</p>
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
device: SessionResponseDto;
|
||||
session: SessionResponseDto;
|
||||
onDelete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
let { device, onDelete = undefined }: Props = $props();
|
||||
const { session, onDelete = undefined }: Props = $props();
|
||||
|
||||
const options: ToRelativeCalendarOptions = {
|
||||
unit: 'days',
|
||||
@@ -32,21 +32,21 @@
|
||||
|
||||
<div class="flex w-full flex-row">
|
||||
<div class="hidden items-center justify-center pe-2 text-primary sm:flex">
|
||||
{#if device.deviceOS === 'Android'}
|
||||
{#if session.deviceOS === 'Android'}
|
||||
<Icon icon={mdiAndroid} size="40" />
|
||||
{:else if device.deviceOS === 'iOS' || device.deviceOS === 'macOS'}
|
||||
{:else if session.deviceOS === 'iOS' || session.deviceOS === 'macOS'}
|
||||
<Icon icon={mdiApple} size="40" />
|
||||
{:else if device.deviceOS.includes('Safari')}
|
||||
{:else if session.deviceOS.includes('Safari')}
|
||||
<Icon icon={mdiAppleSafari} size="40" />
|
||||
{:else if device.deviceOS.includes('Windows')}
|
||||
{:else if session.deviceOS.includes('Windows')}
|
||||
<Icon icon={mdiMicrosoftWindows} size="40" />
|
||||
{:else if device.deviceOS === 'Linux'}
|
||||
{:else if session.deviceOS === 'Linux'}
|
||||
<Icon icon={mdiLinux} size="40" />
|
||||
{:else if device.deviceOS === 'Ubuntu'}
|
||||
{:else if session.deviceOS === 'Ubuntu'}
|
||||
<Icon icon={mdiUbuntu} size="40" />
|
||||
{:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'}
|
||||
{:else if session.deviceOS === 'Chrome OS' || session.deviceType === 'Chrome' || session.deviceType === 'Chromium' || session.deviceType === 'Mobile Chrome'}
|
||||
<Icon icon={mdiGoogleChrome} size="40" />
|
||||
{:else if device.deviceOS === 'Google Cast'}
|
||||
{:else if session.deviceOS === 'Google Cast'}
|
||||
<Icon icon={mdiCast} size="40" />
|
||||
{:else}
|
||||
<Icon icon={mdiHelp} size="40" />
|
||||
@@ -55,24 +55,28 @@
|
||||
<div class="flex grow flex-row justify-between gap-1 ps-4 sm:ps-0">
|
||||
<div class="flex flex-col justify-center gap-1 dark:text-white">
|
||||
<span class="text-sm">
|
||||
{#if device.deviceType || device.deviceOS}
|
||||
<span>{device.deviceOS || $t('unknown')} • {device.deviceType || $t('unknown')}</span>
|
||||
{#if session.deviceType || session.deviceOS}
|
||||
<span
|
||||
>{session.deviceOS || $t('unknown')} • {session.deviceType || $t('unknown')}{session.appVersion
|
||||
? `(v${session.appVersion})`
|
||||
: ''}</span
|
||||
>
|
||||
{:else}
|
||||
<span>{$t('unknown')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="text-sm">
|
||||
<span class="">{$t('last_seen')}</span>
|
||||
<span>{DateTime.fromISO(device.updatedAt, { locale: $locale }).toRelativeCalendar(options)}</span>
|
||||
<span>{DateTime.fromISO(session.updatedAt, { locale: $locale }).toRelativeCalendar(options)}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> - </span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{DateTime.fromISO(device.updatedAt, { locale: $locale }).toLocaleString(DateTime.DATETIME_MED, {
|
||||
{DateTime.fromISO(session.updatedAt, { locale: $locale }).toLocaleString(DateTime.DATETIME_MED, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if !device.current && onDelete}
|
||||
{#if !session.current && onDelete}
|
||||
<div>
|
||||
<IconButton
|
||||
color="danger"
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
const refresh = () => getSessions().then((_devices) => (devices = _devices));
|
||||
|
||||
let currentDevice = $derived(devices.find((device) => device.current));
|
||||
let otherDevices = $derived(devices.filter((device) => !device.current));
|
||||
let currentSession = $derived(devices.find((device) => device.current));
|
||||
let otherSessions = $derived(devices.filter((device) => !device.current));
|
||||
|
||||
const handleDelete = async (device: SessionResponseDto) => {
|
||||
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_this_device_confirmation') });
|
||||
@@ -54,22 +54,22 @@
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
{#if currentDevice}
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('current_device')}
|
||||
</h3>
|
||||
<DeviceCard device={currentDevice} />
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherDevices.length > 0}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('other_devices')}
|
||||
</h3>
|
||||
{#each otherDevices as device, index (device.id)}
|
||||
<DeviceCard {device} onDelete={() => handleDelete(device)} />
|
||||
{#if index !== otherDevices.length - 1}
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -25,7 +25,6 @@ export enum AppRoute {
|
||||
ADMIN_STATS = '/admin/server-status',
|
||||
ADMIN_JOBS = '/admin/jobs-status',
|
||||
ADMIN_REPAIR = '/admin/repair',
|
||||
ADMIN_PLUGINS = '/admin/plugins',
|
||||
|
||||
ALBUMS = '/albums',
|
||||
LIBRARIES = '/libraries',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge } from '@immich/ui';
|
||||
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -9,35 +9,29 @@
|
||||
|
||||
<Modal title={$t('app_download_links')} size="large" {onClose}>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="fdroid-link">
|
||||
F-Droid
|
||||
</label>
|
||||
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
|
||||
<img class="pt-2 pr-10" alt="Get it on F-Droid" src={fdroidBadge} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="play-store-link">
|
||||
Google Play
|
||||
</label>
|
||||
<div class="sm:grid sm:grid-cols-2 gap-5">
|
||||
<div class="flex flex-col place-items-start">
|
||||
<Text>Google Play</Text>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=app.alextran.immich"
|
||||
target="_blank"
|
||||
id="play-store-link"
|
||||
>
|
||||
<img alt="Get it on Google Play" src={playStoreBadge} />
|
||||
<img class="w-[200px] mt-2" alt="Get it on Google Play" src={playStoreBadge} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="app-store-link">
|
||||
App Store
|
||||
</label>
|
||||
<div class="flex flex-col place-items-start">
|
||||
<Text>App Store</Text>
|
||||
<a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
|
||||
<img class="pt-2 pr-5" alt="Download on the App Store" src={appStoreBadge} width="90%" />
|
||||
<img class="w-[200px] mt-2" alt="Download on the App Store" src={appStoreBadge} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col place-items-start">
|
||||
<Text>F-Droid</Text>
|
||||
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
|
||||
<img class="w-[200px] mt-2" alt="Get it on F-Droid" src={fdroidBadge} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createApiKey, Permission } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, obtainiumBadge } from '@immich/ui';
|
||||
import { Button, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
let inputUrl = $state(location.origin);
|
||||
let inputApiKey = $state('');
|
||||
@@ -31,64 +31,53 @@
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={$t('obtainium_configurator')} size="large" {onClose}>
|
||||
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div>
|
||||
<label
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
|
||||
for="obtainium-configurator"
|
||||
>
|
||||
Obtainium
|
||||
</label>
|
||||
<div id="obtainium-configurator">
|
||||
<form>
|
||||
<div class="mt-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('api_key')}
|
||||
bind:value={inputApiKey}
|
||||
/>
|
||||
</div>
|
||||
<div class="">
|
||||
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<SettingSelect
|
||||
label={$t('app_architecture_variant')}
|
||||
bind:value={archVariant}
|
||||
options={[
|
||||
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
|
||||
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
|
||||
{ value: 'release', text: 'universal' },
|
||||
{ value: 'x86_64-release', text: 'x86_64' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<Text color="muted" size="small">
|
||||
{$t('obtainium_configurator_instructions')}
|
||||
</Text>
|
||||
<form class="mt-4">
|
||||
<div class="mt-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-center">
|
||||
{#if inputUrl && inputApiKey && archVariant}
|
||||
<a
|
||||
href={obtainiumLink}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="obtainium-link"
|
||||
>
|
||||
<img class="pt-2 pr-5" alt="Get it on Obtainium" src={obtainiumBadge} />
|
||||
</a>
|
||||
{:else}
|
||||
<p class="immich-form-label pb-2 text-sm" id="obtainium-link">
|
||||
{$t('obtainium_configurator_instructions')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2 place-items-center place-content-center">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('api_key')} bind:value={inputApiKey} />
|
||||
|
||||
<div class="translate-y-[3px]">
|
||||
<Button size="small" onclick={() => handleCreate()}>{$t('create_api_key')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('app_architecture_variant')}
|
||||
bind:value={archVariant}
|
||||
options={[
|
||||
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
|
||||
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
|
||||
{ value: 'release', text: 'universal' },
|
||||
{ value: 'x86_64-release', text: 'x86_64' },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{#if inputUrl && inputApiKey && archVariant}
|
||||
<div class="content-center">
|
||||
<hr />
|
||||
<div class="flex place-items-center place-content-center">
|
||||
<a
|
||||
href={obtainiumLink}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="obtainium-link"
|
||||
>
|
||||
<img class="pt-2 pr-5 h-[80px]" alt="Get it on Obtainium" src={obtainiumBadge} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { NavbarItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiConnection, mdiServer, mdiSync } from '@mdi/js';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
|
||||
<NavbarItem title={$t('plugins')} href={AppRoute.ADMIN_PLUGINS} icon={mdiConnection} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
import { retrieveServerConfig } from '$lib/stores/server-config.store';
|
||||
import { initLanguage } from '$lib/utils';
|
||||
import { AbortError, initLanguage, sleep } from '$lib/utils';
|
||||
import { defaults } from '@immich/sdk';
|
||||
import { memoize } from 'lodash-es';
|
||||
|
||||
type Fetch = typeof fetch;
|
||||
|
||||
const api_server: string = '@IMMICH_API_SERVER@';
|
||||
|
||||
const tryServers = async (fetchFn: typeof fetch) => {
|
||||
const servers = api_server
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '');
|
||||
if (servers.length === 0) {
|
||||
return true;
|
||||
}
|
||||
// servers are in priority order, try in parallel, use first success
|
||||
const fetchers = servers.map(async (url: string) => {
|
||||
const response = await fetchFn(url + '/server/config');
|
||||
if (response.ok) {
|
||||
return url;
|
||||
}
|
||||
throw new AbortError();
|
||||
});
|
||||
try {
|
||||
const urlWinner = await Promise.any(fetchers);
|
||||
defaults.baseUrl = urlWinner;
|
||||
defaults.fetch = (url, options) => fetchFn(url, { credentials: 'include', ...options });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
async function _init(fetch: Fetch) {
|
||||
// set event.fetch on the fetch-client used by @immich/sdk
|
||||
// https://kit.svelte.dev/docs/load#making-fetch-requests
|
||||
// https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options
|
||||
defaults.fetch = fetch;
|
||||
try {
|
||||
await Promise.race([tryServers(fetch), sleep(5000)]);
|
||||
} catch {
|
||||
throw 'Could not connect to any server';
|
||||
}
|
||||
await initLanguage();
|
||||
await retrieveServerConfig();
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import { Button, Icon, Switch } from '@immich/ui';
|
||||
import { mdiCheckDecagram, mdiWrench } from '@mdi/js';
|
||||
import { range } from 'lodash-es';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
const plugins = range(0, 8).map((index) => ({
|
||||
name: `Plugin-${index}`,
|
||||
description: `Plugin ${index} is awesome because it can do x and even y!`,
|
||||
isEnabled: Math.random() < 0.5,
|
||||
isInstalled: Math.random() < 0.5,
|
||||
isOfficial: Math.random() < 0.5,
|
||||
version: 1,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<AdminPageLayout title={data.meta.title}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Button leadingIcon={mdiWrench} onclick={() => console.log('clicked')}>Test</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{#each plugins as plugin, i (i)}
|
||||
<section
|
||||
class="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 items-start flex gap-2">
|
||||
{plugin.name}
|
||||
{#if plugin.isOfficial}
|
||||
<Icon icon={mdiCheckDecagram} size="18" class="text-success" />
|
||||
{/if}
|
||||
<div class="place-self-end justify-self-end justify-end self-end">Version {plugin.version}</div>
|
||||
</h1>
|
||||
|
||||
<p class="m-0 text-sm text-gray-600 dark:text-gray-300">{plugin.description}</p>
|
||||
</div>
|
||||
<div class="flex">Is {plugin.isInstalled ? '' : 'not '}installed</div>
|
||||
<Switch checked={plugin.isEnabled} id={plugin.name} title="Enabled" />
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
@@ -1,16 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
const plugins = [];
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
plugins,
|
||||
meta: {
|
||||
title: $t('plugins'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -6,6 +6,7 @@
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
@@ -36,6 +37,7 @@
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiAppsBox,
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
@@ -60,11 +62,10 @@
|
||||
let user = $derived(data.user);
|
||||
const userPreferences = $derived(data.userPreferences);
|
||||
const userStatistics = $derived(data.userStatistics);
|
||||
|
||||
const userSessions = $derived(data.userSessions);
|
||||
const TiB = 1024 ** 4;
|
||||
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
||||
|
||||
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
|
||||
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
||||
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
||||
@@ -350,6 +351,25 @@
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiAppsBox} size="1.5rem" />
|
||||
<CardTitle>Sessions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
<Stack gap={3}>
|
||||
{#each userSessions as session (session.id)}
|
||||
<DeviceCard {session} />
|
||||
{:else}
|
||||
<span class="text-subtle">No mobile devices</span>
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
@@ -13,9 +13,10 @@ export const load = (async ({ params, url }) => {
|
||||
redirect(302, AppRoute.ADMIN_USERS);
|
||||
}
|
||||
|
||||
const [userPreferences, userStatistics] = await Promise.all([
|
||||
const [userPreferences, userStatistics, userSessions] = await Promise.all([
|
||||
getUserPreferencesAdmin({ id: user.id }),
|
||||
getUserStatisticsAdmin({ id: user.id }),
|
||||
getUserSessionsAdmin({ id: user.id }),
|
||||
]);
|
||||
|
||||
const $t = await getFormatter();
|
||||
@@ -24,6 +25,7 @@ export const load = (async ({ params, url }) => {
|
||||
user,
|
||||
userPreferences,
|
||||
userStatistics,
|
||||
userSessions,
|
||||
meta: {
|
||||
title: $t('admin.user_details'),
|
||||
},
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
component: OnboardingMobileApp,
|
||||
role: OnboardingRole.USER,
|
||||
title: $t('mobile_app'),
|
||||
icon: mdiCellphoneArrowDownVariant, // or you can use mdiCellphone
|
||||
icon: mdiCellphoneArrowDownVariant,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="py-8 flex place-content-center place-items-center m-auto">
|
||||
<div class="py-8 flex place-content-center place-items-center m-auto w-[min(100%,_800px)]">
|
||||
<OnboardingCard
|
||||
title={onboardingSteps[index].title}
|
||||
icon={onboardingSteps[index].icon}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import { enhancedImages } from '@sveltejs/enhanced-img';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
@@ -39,6 +40,16 @@ export default defineConfig({
|
||||
enhancedImages(),
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
include: ['**/server.ts'],
|
||||
sourceMap: true,
|
||||
objectGuards: false,
|
||||
delimiters: ['@', '@'],
|
||||
values: {
|
||||
IMMICH_API_SERVER: process.env.IMMICH_API_SERVER ?? '',
|
||||
},
|
||||
}),
|
||||
process.env.BUILD_STATS === 'true'
|
||||
? visualizer({
|
||||
emitFile: true,
|
||||
|
||||
Reference in New Issue
Block a user