Compare commits

...

7 Commits

Author SHA1 Message Date
midzelis
6fd39767db feat: add configurable API server URLs and CORS settings for development
Adds environment variables to configure API server URLs and CORS settings
for development environments, enabling flexible multi-server setups and
cross-origin testing.
2025-10-23 03:04:53 +00:00
bo0tzz
fc5fc58759 fix: bump tofu (#23152) 2025-10-22 11:13:03 +00:00
bo0tzz
9bb2fc238a fix: don't use app for final close-duplicates request (#23143) 2025-10-22 11:00:31 +00:00
Alex
76f5036026 chore: improve onboarding, app download links styling (#23134) 2025-10-21 21:10:12 -05:00
aviv926
032de9ff2f feat: view the user's app version on the user page (#21345)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-10-22 00:36:18 +02:00
shenlong
c3a533ab40 chore(dep): bump flutter to 3.35.6 (#23120)
* chore(dep): bump flutter to 3.35.6

* chore(dep): bump flutter to 3.35.6 (#23121)

chore(dep): remove unused pub deps

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-10-21 15:46:48 +00:00
Rui Gonçalves
dbd6dcb786 fix(server): use GPSLongitudeRef and GPSLatitudeRef EXIF fields (#21445)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-10-21 16:12:22 +02:00
53 changed files with 581 additions and 247 deletions

View File

@@ -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 \

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -119,5 +119,6 @@ export const deviceDto = {
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
appVersion: null,
},
};

View File

@@ -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",

View File

@@ -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"

View File

@@ -1,3 +1,3 @@
{
"flutter": "3.35.4"
"flutter": "3.35.6"
}

View File

@@ -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]

View File

@@ -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 |

View File

@@ -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].

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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"

View File

@@ -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

View File

@@ -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",
@@ -13267,6 +13315,7 @@
"adminUser.read",
"adminUser.update",
"adminUser.delete",
"adminSession.read",
"adminAuth.unlinkAll"
],
"type": "string"
@@ -14303,6 +14352,10 @@
},
"SessionCreateResponseDto": {
"properties": {
"appVersion": {
"nullable": true,
"type": "string"
},
"createdAt": {
"type": "string"
},
@@ -14332,6 +14385,7 @@
}
},
"required": [
"appVersion",
"createdAt",
"current",
"deviceOS",
@@ -14345,6 +14399,10 @@
},
"SessionResponseDto": {
"properties": {
"appVersion": {
"nullable": true,
"type": "string"
},
"createdAt": {
"type": "string"
},
@@ -14371,6 +14429,7 @@
}
},
"required": [
"appVersion",
"createdAt",
"current",
"deviceOS",

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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

View File

@@ -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",

View File

@@ -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(

View File

@@ -238,6 +238,7 @@ export type Session = {
expiresAt: Date | null;
deviceOS: string;
deviceType: string;
appVersion: string | null;
pinExpiresAt: Date | null;
isPendingSyncReset: boolean;
};
@@ -308,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',

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -236,6 +236,8 @@ export enum Permission {
AdminUserUpdate = 'adminUser.update',
AdminUserDelete = 'adminUser.delete',
AdminSessionRead = 'adminSession.read',
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}

View File

@@ -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;

View File

@@ -23,6 +23,7 @@ select
"session"."id",
"session"."updatedAt",
"session"."pinExpiresAt",
"session"."appVersion",
(
select
to_json(obj)

View File

@@ -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;
}

View File

@@ -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'],

View File

@@ -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);
}

View File

@@ -42,6 +42,9 @@ export class SessionTable {
@Column({ default: '' })
deviceOS!: Generated<string>;
@Column({ nullable: true })
appVersion!: string | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;

View File

@@ -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);

View File

@@ -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,
});

View File

@@ -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];
}

View File

@@ -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);

View File

@@ -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,
};
};

View File

@@ -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() });

View File

@@ -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 => {

View File

@@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
userId: newUuid(),
pinExpiresAt: newDate(),
isPendingSyncReset: false,
appVersion: session.appVersion ?? null,
...session,
});

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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'),
},

View File

@@ -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}

View File

@@ -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,