Compare commits

..

14 Commits

Author SHA1 Message Date
bo0tzz 00f83e7c66 feat: oauth re-link via admin-provided token 2026-04-23 13:11:34 +02:00
bo0tzz 2da2bef777 fix: review notes, new register endpoint 2026-04-23 12:22:27 +02:00
bo0tzz fd52481582 fix: review notes 2026-04-20 14:31:04 +02:00
bo0tzz e583e3c55a chore: remove userEmail to email 2026-04-18 17:28:21 +02:00
bo0tzz 12e36ad082 chore: remove deleteByEmail 2026-04-18 17:25:15 +02:00
bo0tzz f4e016edb5 chore: move clearCookie to finally 2026-04-18 15:25:22 +02:00
bo0tzz d50ea005a1 feat: manage link token via cookie instead 2026-04-18 13:46:30 +02:00
bo0tzz b8c373f0f1 chore: rename linkToken to oauthLinkToken 2026-04-18 13:46:30 +02:00
bo0tzz b3e5ec48e6 fix: oauthlink cleanup mock 2026-04-18 13:46:29 +02:00
bo0tzz 058bd40708 fix: await goto 2026-04-18 13:46:29 +02:00
bo0tzz 81a885c31d fix: migration 2026-04-18 13:46:29 +02:00
bo0tzz 9b7f75a407 fix: email normalization test 2026-04-18 13:46:29 +02:00
bo0tzz b42fdcfca9 fix: review notes 2026-04-18 13:46:29 +02:00
bo0tzz 5731c261eb fix: require users to authenticate existing Immich account before OAuth linking 2026-04-18 13:46:29 +02:00
77 changed files with 3121 additions and 2281 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api |
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
+43 -44
View File
@@ -205,30 +205,39 @@ describe(`/oauth`, () => {
expect(status).toBeGreaterThanOrEqual(400);
});
it('should auto register the user by default', async () => {
it('should return a link token for a new OAuth user', async () => {
const callbackParams = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
isAdmin: false,
name: 'OAuth User',
userEmail: 'oauth-auto-register@immich.app',
userId: expect.any(String),
});
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
const setCookie = response.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((cookie) => cookie.startsWith('immich_oauth_link_token='))).toBe(true);
});
it('should allow passing state and codeVerifier via cookies', async () => {
const { url, state, codeVerifier } = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app)
const response = await request(app)
.post('/oauth/callback')
.set('Cookie', [`immich_oauth_state=${state}`, `immich_oauth_code_verifier=${codeVerifier}`])
.send({ url });
expect(status).toBe(201);
expect(body).toMatchObject({
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
it('should register a new user via POST /auth/register using the link token cookie', async () => {
const callbackParams = await loginWithOAuth('oauth-register-flow');
const callbackResponse = await request(app).post('/oauth/callback').send(callbackParams);
expect(callbackResponse.status).toBe(403);
const setCookie = callbackResponse.headers['set-cookie'] as unknown as string[];
const linkCookie = setCookie.find((cookie) => cookie.startsWith('immich_oauth_link_token='));
expect(linkCookie).toBeDefined();
const registerResponse = await request(app).post('/auth/register').set('Cookie', linkCookie!);
expect(registerResponse.status).toBe(201);
expect(registerResponse.body).toMatchObject({
accessToken: expect.any(String),
userEmail: 'oauth-register-flow@immich.app',
userId: expect.any(String),
userEmail: 'oauth-auto-register@immich.app',
});
});
@@ -349,26 +358,27 @@ describe(`/oauth`, () => {
});
});
it('should not auto register the user', async () => {
it('should still create a link token when auto register is disabled', async () => {
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
it('should link to an existing user by email', async () => {
const { userId } = await utils.userSetup(admin.accessToken, {
it('should not auto-link to an existing user by email', async () => {
await utils.userSetup(admin.accessToken, {
name: 'OAuth User 3',
email: 'oauth-user3@immich.app',
password: 'password',
});
const callbackParams = await loginWithOAuth('oauth-user3');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
userId,
userEmail: 'oauth-user3@immich.app',
});
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
expect(response.body.userEmail).toBe('oauth-user3@immich.app');
expect(response.body.oauthLinkToken).toBeUndefined();
const setCookie = response.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((cookie) => cookie.startsWith('immich_oauth_link_token='))).toBe(true);
});
});
});
@@ -444,24 +454,18 @@ describe(`/oauth`, () => {
expect(params.get('state')).toBeDefined();
});
it('should auto register the user by default', async () => {
it('should return a link token for a new OAuth user via mobile redirect', async () => {
const callbackParams = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
expect(callbackParams.url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
// simulate redirecting back to mobile app
const url = callbackParams.url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
const { status, body } = await request(app)
const response = await request(app)
.post('/oauth/callback')
.send({ ...callbackParams, url });
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
isAdmin: false,
name: 'OAuth User',
userEmail: 'oauth-mobile-override@immich.app',
userId: expect.any(String),
});
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
});
@@ -473,14 +477,9 @@ describe(`/oauth`, () => {
clientSecret: OAuthClient.DEFAULT,
});
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
name: 'ID Token User',
userEmail: 'oauth-id-token-claims@immich.app',
userId: expect.any(String),
});
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
});
+7
View File
@@ -853,6 +853,7 @@
"create_link_to_share": "Create link to share",
"create_link_to_share_description": "Let anyone with the link see the selected photo(s)",
"create_new": "CREATE NEW",
"create_new_account": "Create new account",
"create_new_face": "Create new face",
"create_new_person": "Create new person",
"create_new_person_hint": "Assign selected assets to a new person",
@@ -1125,6 +1126,7 @@
"unable_to_hide_person": "Unable to hide person",
"unable_to_link_motion_video": "Unable to link motion video",
"unable_to_link_oauth_account": "Unable to link OAuth account",
"invalid_oauth_relink_token": "This OAuth re-link token is invalid or has expired",
"unable_to_log_out_all_devices": "Unable to log out all devices",
"unable_to_log_out_device": "Unable to log out device",
"unable_to_login_with_oauth": "Unable to login with OAuth",
@@ -1642,6 +1644,11 @@
"notifications": "Notifications",
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
"oauth_account_is_linked": "This account is linked to an OAuth identity. Logging in via OAuth will sign you in directly.",
"oauth_account_not_linked": "Link this account to an OAuth identity to sign in via your identity provider.",
"oauth_link_existing_account": "Log in with your Immich password to link your OAuth account",
"oauth_relink_in_progress": "Redirecting to your identity provider to complete the re-link...",
"oauth_link_password_login_required": "An account with this email already exists but password login is required to link your OAuth account. Please contact your administrator",
"obtainium_configurator": "Obtainium Configurator",
"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",
"ocr": "OCR",
@@ -43,8 +43,8 @@ class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, ImmichPl
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
super.onAttachedToEngine(binding)
engineCount.incrementAndGet()
checkAndEnforceBackgroundLock(binding.applicationContext)
engineCount.incrementAndGet()
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
}
@@ -5,10 +5,8 @@ import android.provider.MediaStore
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import io.flutter.embedding.engine.FlutterEngineCache
import java.util.concurrent.TimeUnit
@@ -20,7 +18,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
override fun enable() {
enqueueMediaObserver(ctx)
enqueuePeriodicWorker(ctx)
}
override fun saveNotificationMessage(title: String, body: String) {
@@ -30,14 +27,12 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
override fun configure(settings: BackgroundWorkerSettings) {
BackgroundWorkerPreferences(ctx).updateSettings(settings)
enqueueMediaObserver(ctx)
enqueuePeriodicWorker(ctx)
}
override fun disable() {
WorkManager.getInstance(ctx).apply {
cancelUniqueWork(OBSERVER_WORKER_NAME)
cancelUniqueWork(BACKGROUND_WORKER_NAME)
cancelUniqueWork(PERIODIC_WORKER_NAME)
}
Log.i(TAG, "Cancelled background upload tasks")
}
@@ -45,7 +40,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
companion object {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
private const val PERIODIC_WORKER_NAME = "immich/PeriodicBackgroundWorkerV1"
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
@@ -61,7 +55,7 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
setRequiresCharging(settings.requiresCharging)
}.build()
val work = OneTimeWorkRequestBuilder<MediaObserver>()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(ctx)
@@ -73,30 +67,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
)
}
fun enqueuePeriodicWorker(ctx: Context) {
val settings = BackgroundWorkerPreferences(ctx).getSettings()
val constraints = Constraints.Builder().apply {
setRequiresCharging(settings.requiresCharging)
}.build()
val work =
PeriodicWorkRequestBuilder<PeriodicWorker>(
1,
TimeUnit.HOURS,
15,
TimeUnit.MINUTES
).setConstraints(constraints)
.build()
WorkManager.getInstance(ctx)
.enqueueUniquePeriodicWork(PERIODIC_WORKER_NAME, ExistingPeriodicWorkPolicy.UPDATE, work)
Log.i(TAG, "Enqueued periodic background worker with name: $PERIODIC_WORKER_NAME")
}
fun enqueueBackgroundWorker(ctx: Context) {
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
val work = OneTimeWorkRequestBuilder<BackgroundWorker>()
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
@@ -1,16 +0,0 @@
package app.alextran.immich.background
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
class PeriodicWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
private val ctx: Context = context.applicationContext
override fun doWork(): Result {
Log.i("PeriodicWorker", "Periodic worker triggered, starting background worker")
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
return Result.success()
}
}
+1 -1
View File
@@ -46,6 +46,6 @@ material = { module = "com.google.android.material:material", version.ref = "mat
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
# TODO: update to version.ref = "kotlin" when background_downloader is removed
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.1.0" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "1.9.22" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+1 -1
View File
@@ -21,7 +21,7 @@ plugins {
id "com.android.application" version "8.11.2" apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
// TODO: update to match kotlin version when background_downloader is removed
id "org.jetbrains.kotlin.plugin.serialization" version "2.1.0" apply false
id "org.jetbrains.kotlin.plugin.serialization" version "1.9.22" apply false
id "com.google.devtools.ksp" version "2.2.20-2.0.3" apply false
}
+64 -20
View File
@@ -20,21 +20,19 @@ PODS:
- Flutter
- flutter_udid (0.0.1):
- Flutter
- KeychainAccess
- flutter_web_auth_2 (5.0.0):
- SAMKeychain
- flutter_web_auth_2 (3.0.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- KeychainAccess (4.2.2)
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
@@ -46,13 +44,19 @@ PODS:
- Flutter
- network_info_plus (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (3.9.0):
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (3.7.1):
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -66,6 +70,28 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.49.2):
- sqlite3/common (= 3.49.2)
- sqlite3/common (3.49.2)
- sqlite3/dbstatvtab (3.49.2):
- sqlite3/common
- sqlite3/fts5 (3.49.2):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.2):
- sqlite3/common
- sqlite3/rtree (3.49.2):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/perf-threadsafe
- sqlite3/rtree
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
@@ -84,7 +110,7 @@ DEPENDENCIES:
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
@@ -92,20 +118,25 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- KeychainAccess
- MapLibre
- SAMKeychain
- sqlite3
EXTERNAL SOURCES:
background_downloader:
@@ -133,7 +164,7 @@ EXTERNAL SOURCES:
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
:path: ".symlinks/plugins/geolocator_apple/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
@@ -148,12 +179,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/darwin"
:path: ".symlinks/plugins/photo_manager/ios"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
@@ -162,6 +197,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
@@ -177,27 +216,32 @@ SPEC CHECKSUMS:
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
+8
View File
@@ -1,4 +1,12 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
import photo_manager
import shared_preferences_foundation
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+21 -21
View File
@@ -150,27 +150,6 @@
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
@@ -197,6 +176,27 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
+6 -11
View File
@@ -54,9 +54,6 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
var _dynamicSpawning = false;
var _isolatesCount = numberOfProcessors;
@visibleForTesting
UnmodifiableListView<Worker> get pool => UnmodifiableListView(_pool);
@override
Future<void> init({int? isolatesCount, bool? dynamicSpawning}) async {
if (_pool.isNotEmpty) {
@@ -79,9 +76,7 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
Future<void> dispose() async {
_queue.clear();
for (final worker in _pool) {
if (worker.initialized || worker.initializing) {
worker.kill();
}
worker.kill();
}
_pool.clear();
super.dispose();
@@ -162,7 +157,9 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
_nextTaskId++;
late final Task<R> task;
final completer = Completer<R>();
if (execution is ExecuteWithPort<R>) {
if (execution is Execute<R>) {
task = TaskRegular<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteWithPort<R>) {
task = TaskWithPort<R>(
id: id,
workPriority: priority,
@@ -180,8 +177,6 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
completer: completer,
onMessage: onMessage!,
);
} else if (execution is Execute<R>) {
task = TaskRegular<R>(id: id, workPriority: priority, execution: execution, completer: completer);
}
_queue.add(task);
_schedule();
@@ -204,7 +199,7 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
if (_pool.every((worker) => worker.taskId != null)) {
return;
}
if (_dynamicSpawning && _queue.isNotEmpty) {
if (_dynamicSpawning) {
final freeWorker = _pool.firstWhereOrNull(
(worker) => worker.taskId == null && !worker.initialized && !worker.initializing,
);
@@ -226,7 +221,7 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
.work(task)
.then(
(value) {
//might be completed by cancel and it is normal.
//could be completed already by cancel and it is normal.
//Assuming that worker finished with error and cleaned gracefully
task.complete(value, null, null);
},
-1
View File
@@ -122,7 +122,6 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | Retrieve auth status
*AuthenticationApi* | [**linkOAuthAccount**](doc//AuthenticationApi.md#linkoauthaccount) | **POST** /oauth/link | Link OAuth account
*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | Lock auth session
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | Login
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | Logout
-56
View File
@@ -224,62 +224,6 @@ class AuthenticationApi {
return null;
}
/// Link OAuth account
///
/// Link an OAuth account to the authenticated user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<Response> linkOAuthAccountWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/oauth/link';
// ignore: prefer_final_locals
Object? postBody = oAuthCallbackDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Link OAuth account
///
/// Link an OAuth account to the authenticated user.
///
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<UserAdminResponseDto?> linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async {
final response = await linkOAuthAccountWithHttpInfo(oAuthCallbackDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
}
return null;
}
/// Lock auth session
///
/// Remove elevated access to locked assets from the current session.
+3
View File
@@ -24,11 +24,13 @@ class AssetMediaStatus {
String toJson() => value;
static const created = AssetMediaStatus._(r'created');
static const replaced = AssetMediaStatus._(r'replaced');
static const duplicate = AssetMediaStatus._(r'duplicate');
/// List of all possible values in this [enum][AssetMediaStatus].
static const values = <AssetMediaStatus>[
created,
replaced,
duplicate,
];
@@ -69,6 +71,7 @@ class AssetMediaStatusTypeTransformer {
if (data != null) {
switch (data) {
case r'created': return AssetMediaStatus.created;
case r'replaced': return AssetMediaStatus.replaced;
case r'duplicate': return AssetMediaStatus.duplicate;
default:
if (!allowNull) {
+211 -139
View File
@@ -29,10 +29,10 @@ packages:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
sha256: "0c64e928dcbefddecd234205422bcfc2b5e6d31be0b86fef0d0dd48d7b4c9742"
url: "https://pub.dev"
source: hosted
version: "4.0.9"
version: "4.0.4"
args:
dependency: transitive
description:
@@ -45,10 +45,10 @@ packages:
dependency: "direct main"
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.1"
version: "2.13.0"
auto_route:
dependency: "direct main"
description:
@@ -69,12 +69,12 @@ packages:
dependency: "direct main"
description:
name: background_downloader
sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06"
sha256: a913b37cc47a656a225e9562b69576000d516f705482f392e2663500e6ff6032
url: "https://pub.dev"
source: hosted
version: "9.5.4"
version: "9.3.0"
bonsoir:
dependency: "direct overridden"
dependency: transitive
description:
name: bonsoir
sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496"
@@ -149,10 +149,10 @@ packages:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
version: "4.0.4"
build_runner:
dependency: "direct dev"
description:
@@ -205,10 +205,10 @@ packages:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.3"
cli_util:
dependency: transitive
description:
@@ -261,10 +261,10 @@ packages:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.0.1"
convert:
dependency: transitive
description:
@@ -277,26 +277,26 @@ packages:
dependency: "direct main"
description:
name: crop_image
sha256: "27cbce1685a595efee62caab81c98b49b636f765c1da86353f58f5b2bf2775d8"
sha256: "4fdebd00d0c7d1a6e3abeb1e3843efbc202204b867f3e377fcebcf77aaf69a17"
url: "https://pub.dev"
source: hosted
version: "1.0.17"
version: "1.0.16"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
version: "0.3.4+2"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.7"
version: "3.0.6"
csslib:
dependency: transitive
description:
@@ -326,10 +326,10 @@ packages:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.11"
desktop_webview_window:
dependency: transitive
description:
@@ -342,10 +342,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33
url: "https://pub.dev"
source: hosted
version: "12.4.0"
version: "12.2.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@@ -414,10 +414,10 @@ packages:
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.4"
file:
dependency: "direct dev"
description:
@@ -430,34 +430,34 @@ packages:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
version: "0.9.3+2"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
version: "0.9.4+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
url: "https://pub.dev"
source: hosted
version: "2.7.0"
version: "2.6.2"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
version: "0.9.3+4"
fixnum:
dependency: transitive
description:
@@ -471,6 +471,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_displaymode:
dependency: "direct main"
description:
@@ -549,10 +557,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
version: "2.0.27"
flutter_riverpod:
dependency: transitive
description:
@@ -613,10 +621,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
url: "https://pub.dev"
source: hosted
version: "2.2.4"
version: "2.2.1"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -626,26 +634,26 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
sha256: fc599671cbe8b328e509c961ec121880406ed994dde659cc9ece9c7503cd31c7
sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
version: "4.0.0"
flutter_web_auth_2:
dependency: "direct main"
description:
name: flutter_web_auth_2
sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e
sha256: "561c32d32ed537853de43852c35849cf1d37f3482f41f22b718ab6112f96b333"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.0.0-alpha.0"
flutter_web_auth_2_platform_interface:
dependency: transitive
description:
name: flutter_web_auth_2_platform_interface
sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab
sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
version: "5.0.0-alpha.0"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -655,10 +663,10 @@ packages:
dependency: "direct main"
description:
name: fluttertoast
sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8"
sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1"
url: "https://pub.dev"
source: hosted
version: "8.2.14"
version: "8.2.12"
frontend_server_client:
dependency: transitive
description:
@@ -692,18 +700,18 @@ packages:
dependency: transitive
description:
name: geolocator_android
sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a"
sha256: "114072db5d1dce0ec0b36af2697f55c133bc89a2c8dd513e137c0afe59696ed4"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.0.1+1"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
sha256: c4ecead17985ede9634f21500072edfcb3dba0ef7b97f8d7bc556d2d722b3ba3
url: "https://pub.dev"
source: hosted
version: "2.3.13"
version: "2.3.9"
geolocator_linux:
dependency: transitive
description:
@@ -716,10 +724,10 @@ packages:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
sha256: "386ce3d9cce47838355000070b1d0b13efb5bc430f8ecda7e9238c8409ace012"
url: "https://pub.dev"
source: hosted
version: "4.2.6"
version: "4.2.4"
geolocator_web:
dependency: transitive
description:
@@ -732,10 +740,10 @@ packages:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
version: "0.2.3"
glob:
dependency: transitive
description:
@@ -804,10 +812,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev"
source: hosted
version: "1.6.0"
version: "1.5.0"
http_multi_server:
dependency: transitive
description:
@@ -836,42 +844,42 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
version: "4.5.4"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.0"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca"
url: "https://pub.dev"
source: hosted
version: "0.8.13+16"
version: "0.8.13+5"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.0"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
sha256: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
version: "0.8.13+1"
image_picker_linux:
dependency: transitive
description:
@@ -892,10 +900,10 @@ packages:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
version: "2.11.0"
image_picker_windows:
dependency: transitive
description:
@@ -952,10 +960,10 @@ packages:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.11.0"
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@@ -1008,26 +1016,26 @@ packages:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e"
url: "https://pub.dev"
source: hosted
version: "1.0.56"
version: "1.0.49"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
version: "1.4.3"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.0.10"
local_auth_windows:
dependency: transitive
description:
@@ -1104,10 +1112,10 @@ packages:
dependency: "direct dev"
description:
name: mocktail
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
version: "1.0.4"
native_toolchain_c:
dependency: transitive
description:
@@ -1129,10 +1137,10 @@ packages:
dependency: "direct main"
description:
name: network_info_plus
sha256: f926b2ba86aa0086a0dfbb9e5072089bc213d854135c1712f1d29fc89ba3c877
sha256: "08f4166bbb77da9e407edef6322a33f87b18c0ca46483fb25606cb3d2bfcdd2a"
url: "https://pub.dev"
source: hosted
version: "6.1.4"
version: "6.1.3"
network_info_plus_platform_interface:
dependency: transitive
description:
@@ -1153,10 +1161,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.1.0"
octo_image:
dependency: "direct main"
description:
@@ -1185,26 +1193,26 @@ packages:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.1"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
version: "8.3.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "3.2.0"
path:
dependency: "direct main"
description:
@@ -1233,18 +1241,18 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev"
source: hosted
version: "2.2.23"
version: "2.2.16"
path_provider_foundation:
dependency: "direct main"
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.4.3"
path_provider_linux:
dependency: transitive
description:
@@ -1289,10 +1297,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98
url: "https://pub.dev"
source: hosted
version: "9.4.7"
version: "9.4.6"
permission_handler_html:
dependency: transitive
description:
@@ -1321,18 +1329,18 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
version: "7.0.1"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
sha256: a0d9a7a9bc35eda02d33766412bde6d883a8b0acb86bbe37dac5f691a0894e8a
url: "https://pub.dev"
source: hosted
version: "3.9.0"
version: "3.7.1"
pigeon:
dependency: "direct dev"
description:
@@ -1369,26 +1377,26 @@ packages:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
version: "1.5.1"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a
url: "https://pub.dev"
source: hosted
version: "6.5.0"
version: "6.0.1"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d"
url: "https://pub.dev"
source: hosted
version: "5.0.5"
version: "5.0.3"
protobuf:
dependency: transitive
description:
@@ -1437,6 +1445,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
scroll_date_picker:
dependency: "direct main"
description:
@@ -1505,26 +1521,26 @@ packages:
dependency: transitive
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a"
url: "https://pub.dev"
source: hosted
version: "2.5.5"
version: "2.5.2"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad"
url: "https://pub.dev"
source: hosted
version: "2.4.23"
version: "2.4.8"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
@@ -1537,10 +1553,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
@@ -1619,6 +1635,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b"
url: "https://pub.dev"
source: hosted
version: "2.5.5"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlcipher_flutter_libs:
dependency: transitive
description:
@@ -1699,6 +1763,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
term_glyph:
dependency: transitive
description:
@@ -1743,10 +1815,10 @@ packages:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
version: "2.2.2"
universal_platform:
dependency: transitive
description:
@@ -1767,34 +1839,34 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
version: "6.3.15"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
version: "6.3.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -1807,34 +1879,34 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
version: "3.1.4"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.3"
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
url: "https://pub.dev"
source: hosted
version: "1.1.21"
version: "1.1.18"
vector_graphics_codec:
dependency: transitive
description:
@@ -1847,10 +1919,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.1.19"
vector_math:
dependency: transitive
description:
@@ -1863,10 +1935,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.1.0"
version: "15.0.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1879,10 +1951,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc"
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.3.0"
watcher:
dependency: transitive
description:
@@ -1927,10 +1999,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "5.11.0"
win32_registry:
dependency: transitive
description:
@@ -1951,10 +2023,10 @@ packages:
dependency: "direct main"
description:
name: worker_manager
sha256: "887587eb97e517bca88dea761bea96edc495513ec91e4c489dcf110967ba79ff"
sha256: "1bce9f894a0c187856f5fc0e150e7fe1facce326f048ca6172947754dac3d4f3"
url: "https://pub.dev"
source: hosted
version: "7.2.9"
version: "7.2.7"
xdg_directories:
dependency: transitive
description:
+26 -29
View File
@@ -9,35 +9,37 @@ environment:
flutter: 3.41.6
dependencies:
async: ^2.13.1
async: ^2.13.0
auto_route: ^11.1.0
background_downloader: ^9.5.4
background_downloader: ^9.3.0
cast: ^2.1.0
collection: ^1.19.1
connectivity_plus: ^6.1.5
crop_image: ^1.0.17
crypto: ^3.0.7
device_info_plus: ^12.4.0
connectivity_plus: ^6.1.3
crop_image: ^1.0.16
crypto: ^3.0.6
device_info_plus: ^12.2.0
# DB
drift: ^2.32.1
drift_flutter: ^0.3.0
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
ffi: ^2.2.0
ffi: ^2.1.4
flutter:
sdk: flutter
flutter_cache_manager: ^3.4.1
flutter_displaymode: ^0.7.0
flutter_hooks: ^0.21.3+1
flutter_local_notifications: ^17.2.4
flutter_local_notifications: ^17.2.1+2
flutter_secure_storage: ^9.2.4
flutter_svg: ^2.2.4
flutter_udid: ^4.1.2
flutter_web_auth_2: ^5.0.2
fluttertoast: ^8.2.14
flutter_svg: ^2.2.1
flutter_udid: ^4.0.0
flutter_web_auth_2: ^5.0.0-alpha.0
fluttertoast: ^8.2.12
geolocator: ^14.0.2
home_widget: ^0.8.1
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
http: ^1.5.0
image_picker: ^1.2.0
immich_ui:
path: './packages/ui'
intl: ^0.20.2
@@ -48,16 +50,16 @@ dependencies:
git:
url: https://github.com/immich-app/native_video_player
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.4
network_info_plus: ^6.1.3
octo_image: ^2.1.0
openapi:
path: openapi
package_info_plus: ^8.3.1
package_info_plus: ^8.3.0
path: ^1.9.1
path_provider: ^2.1.5
path_provider_foundation: ^2.6.0
path_provider_foundation: ^2.4.3
permission_handler: ^11.4.0
photo_manager: ^3.9.0
photo_manager: ^3.7.1
pinput: ^5.0.2
punycode: ^1.0.0
scroll_date_picker: ^3.8.0
@@ -69,9 +71,9 @@ dependencies:
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
uuid: ^4.5.3
wakelock_plus: ^1.3.3
worker_manager: ^7.2.9
uuid: ^4.5.1
wakelock_plus: ^1.3.0
worker_manager: ^7.2.7
web_socket: ^1.0.1
socket_io_client:
git:
@@ -90,7 +92,7 @@ dependencies:
dev_dependencies:
auto_route_generator: ^10.5.0
build_runner: ^2.13.1
build_runner: ^2.4.8
# Drift generator
drift_dev: ^2.32.1
fake_async: ^1.3.3
@@ -102,14 +104,9 @@ dev_dependencies:
sdk: flutter
integration_test:
sdk: flutter
mocktail: ^1.0.5
mocktail: ^1.0.4
# Type safe platform code
pigeon: ^26.3.4
# cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API.
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
dependency_overrides:
bonsoir: ^5.1.11
pigeon: ^26.0.2
flutter:
uses-material-design: true
+152 -59
View File
@@ -1285,6 +1285,59 @@
"x-immich-state": "Stable"
}
},
"/admin/users/{id}/oauth-relink-token": {
"post": {
"description": "Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.",
"operationId": "createOAuthReLinkTokenAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthReLinkTokenResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Issue an OAuth re-link token",
"tags": [
"Users (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v2",
"state": "Added"
}
],
"x-immich-permission": "adminUser.update"
}
},
"/admin/users/{id}/preferences": {
"get": {
"description": "Retrieve the preferences of a specific user.",
@@ -4660,6 +4713,35 @@
"x-immich-state": "Stable"
}
},
"/auth/register": {
"post": {
"description": "Create a new user from a pending OAuth link token (requires OAuth auto-register to be enabled).",
"operationId": "register",
"parameters": [],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponseDto"
}
}
},
"description": ""
}
},
"summary": "Register via OAuth",
"tags": [
"Authentication"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
}
]
}
},
"/auth/session/lock": {
"post": {
"description": "Remove elevated access to locked assets from the current session.",
@@ -7439,65 +7521,6 @@
"x-immich-state": "Stable"
}
},
"/oauth/link": {
"post": {
"description": "Link an OAuth account to the authenticated user.",
"operationId": "linkOAuthAccount",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthCallbackDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Link OAuth account",
"tags": [
"Authentication"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v1",
"state": "Beta"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
}
},
"/oauth/mobile-redirect": {
"get": {
"description": "Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting.",
@@ -7529,6 +7552,38 @@
"x-immich-state": "Stable"
}
},
"/oauth/relink-start": {
"post": {
"description": "Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.",
"operationId": "startOAuthReLink",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthReLinkStartDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"summary": "Start OAuth re-link",
"tags": [
"Authentication"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
}
]
}
},
"/oauth/unlink": {
"post": {
"description": "Unlink the OAuth account from the authenticated user.",
@@ -16348,6 +16403,7 @@
"description": "Upload status",
"enum": [
"created",
"replaced",
"duplicate"
],
"type": "string"
@@ -19115,6 +19171,38 @@
],
"type": "object"
},
"OAuthReLinkStartDto": {
"properties": {
"token": {
"description": "Plaintext OAuth re-link token issued by an administrator",
"type": "string"
}
},
"required": [
"token"
],
"type": "object"
},
"OAuthReLinkTokenResponseDto": {
"properties": {
"expiresAt": {
"description": "Token expiration",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"token": {
"description": "Single-use token; deliver to the user via /auth/link?token=<token>",
"type": "string"
}
},
"required": [
"expiresAt",
"token"
],
"type": "object"
},
"OAuthTokenEndpointAuthMethod": {
"description": "OAuth token endpoint auth method",
"enum": [
@@ -21188,6 +21276,10 @@
"description": "Whether OAuth auto-launch is enabled",
"type": "boolean"
},
"oauthAutoRegister": {
"description": "Whether OAuth auto-register is enabled",
"type": "boolean"
},
"ocr": {
"description": "Whether OCR is enabled",
"type": "boolean"
@@ -21226,6 +21318,7 @@
"map",
"oauth",
"oauthAutoLaunch",
"oauthAutoRegister",
"ocr",
"passwordLogin",
"reverseGeocoding",
+51 -15
View File
@@ -262,6 +262,12 @@ export type UserAdminUpdateDto = {
/** Storage label */
storageLabel?: string | null;
};
export type OAuthReLinkTokenResponseDto = {
/** Token expiration */
expiresAt: string;
/** Single-use token; deliver to the user via /auth/link?token=<token> */
token: string;
};
export type AlbumsResponse = {
defaultAssetOrder: AssetOrder;
};
@@ -1421,6 +1427,10 @@ export type OAuthCallbackDto = {
/** OAuth callback URL */
url: string;
};
export type OAuthReLinkStartDto = {
/** Plaintext OAuth re-link token issued by an administrator */
token: string;
};
export type PartnerResponseDto = {
avatarColor: UserAvatarColor;
/** User email */
@@ -2047,6 +2057,8 @@ export type ServerFeaturesDto = {
oauth: boolean;
/** Whether OAuth auto-launch is enabled */
oauthAutoLaunch: boolean;
/** Whether OAuth auto-register is enabled */
oauthAutoRegister: boolean;
/** Whether OCR is enabled */
ocr: boolean;
/** Whether password login is enabled */
@@ -3525,6 +3537,20 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: {
body: userAdminUpdateDto
})));
}
/**
* Issue an OAuth re-link token
*/
export function createOAuthReLinkTokenAdmin({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: OAuthReLinkTokenResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}/oauth-relink-token`, {
...opts,
method: "POST"
}));
}
/**
* Retrieve user preferences
*/
@@ -4304,6 +4330,18 @@ export function changePinCode({ pinCodeChangeDto }: {
body: pinCodeChangeDto
})));
}
/**
* Register via OAuth
*/
export function register(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: LoginResponseDto;
}>("/auth/register", {
...opts,
method: "POST"
}));
}
/**
* Lock auth session
*/
@@ -4944,21 +4982,6 @@ export function finishOAuth({ oAuthCallbackDto }: {
body: oAuthCallbackDto
})));
}
/**
* Link OAuth account
*/
export function linkOAuthAccount({ oAuthCallbackDto }: {
oAuthCallbackDto: OAuthCallbackDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>("/oauth/link", oazapfts.json({
...opts,
method: "POST",
body: oAuthCallbackDto
})));
}
/**
* Redirect OAuth to mobile
*/
@@ -4967,6 +4990,18 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* Start OAuth re-link
*/
export function startOAuthReLink({ oAuthReLinkStartDto }: {
oAuthReLinkStartDto: OAuthReLinkStartDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/oauth/relink-start", oazapfts.json({
...opts,
method: "POST",
body: oAuthReLinkStartDto
})));
}
/**
* Unlink OAuth account
*/
@@ -6899,6 +6934,7 @@ export enum Permission {
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",
Duplicate = "duplicate"
}
export enum AssetUploadAction {
+40 -122
View File
@@ -67,7 +67,7 @@ importers:
version: 24.12.2
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@@ -112,10 +112,10 @@ importers:
version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 0.4.5(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
yaml:
specifier: ^2.3.1
version: 2.8.3
@@ -412,6 +412,9 @@ importers:
'@socket.io/redis-adapter':
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.6)
ajv:
specifier: ^8.17.1
version: 8.18.0
archiver:
specifier: ^7.0.0
version: 7.0.1
@@ -520,6 +523,9 @@ importers:
pg:
specifier: ^8.11.3
version: 8.20.0
pg-connection-string:
specifier: ^2.9.1
version: 2.12.0
picomatch:
specifier: ^4.0.2
version: 4.0.4
@@ -664,7 +670,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.1.0(jiti@2.6.1)
@@ -683,6 +689,9 @@ importers:
mock-fs:
specifier: ^5.2.0
version: 5.5.0
node-gyp:
specifier: ^12.0.0
version: 12.2.0
pngjs:
specifier: ^7.0.0
version: 7.0.0
@@ -718,7 +727,7 @@ importers:
version: 6.1.1(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -775,7 +784,7 @@ importers:
version: 2.6.0
fabric:
specifier: ^7.0.0
version: 7.2.0
version: 7.2.0(encoding@0.1.13)
geo-coordinates-parser:
specifier: ^1.7.4
version: 1.7.4
@@ -881,7 +890,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 5.3.1(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -905,7 +914,7 @@ importers:
version: 1.5.6
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
dotenv:
specifier: ^17.0.0
version: 17.3.1
@@ -949,8 +958,8 @@ importers:
specifier: 5.55.1
version: 5.55.1
svelte-check:
specifier: ^4.4.6
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@6.0.2)
specifier: ^4.1.5
version: 4.4.5(picomatch@4.0.4)(svelte@5.55.1)(typescript@6.0.2)
svelte-eslint-parser:
specifier: ^1.3.3
version: 1.6.0(svelte@5.55.1)
@@ -968,7 +977,7 @@ importers:
version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
packages:
@@ -11319,8 +11328,8 @@ packages:
peerDependencies:
svelte: '>= 3.43.1 < 6'
svelte-check@4.4.6:
resolution: {integrity: sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==}
svelte-check@4.4.5:
resolution: {integrity: sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==}
engines: {node: '>= 18.0.0'}
hasBin: true
peerDependencies:
@@ -15547,22 +15556,6 @@ snapshots:
'@mapbox/mapbox-gl-rtl-text@0.3.0': {}
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
dependencies:
detect-libc: 2.1.2
@@ -16950,14 +16943,14 @@ snapshots:
dependencies:
svelte: 5.55.1
'@testing-library/svelte@5.3.1(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@testing-library/svelte@5.3.1(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.55.1)
svelte: 5.55.1
optionalDependencies:
vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -17652,7 +17645,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17667,11 +17660,11 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@vitest/coverage-v8@4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.2
@@ -17683,9 +17676,9 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/coverage-v8@4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@vitest/coverage-v8@4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.2
@@ -17697,7 +17690,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -18473,16 +18466,6 @@ snapshots:
caniuse-lite@1.0.30001776: {}
canvas@2.11.2:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.26.2
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
canvas@2.11.2(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
@@ -20114,10 +20097,10 @@ snapshots:
extend@3.0.2: {}
fabric@7.2.0:
fabric@7.2.0(encoding@0.1.13):
optionalDependencies:
canvas: 2.11.2
jsdom: 26.1.0(canvas@2.11.2)
canvas: 2.11.2(encoding@0.1.13)
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- bufferutil
- encoding
@@ -21284,36 +21267,6 @@ snapshots:
- utf-8-validate
optional: true
jsdom@26.1.0(canvas@2.11.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
optional: true
jsep@1.4.0: {}
jsesc@3.1.0: {}
@@ -22555,11 +22508,6 @@ snapshots:
emojilib: 2.4.0
skin-tone: 2.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
@@ -24796,7 +24744,7 @@ snapshots:
dependencies:
svelte: 5.55.1
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@6.0.2):
svelte-check@4.4.5(picomatch@4.0.4)(svelte@5.55.1)(typescript@6.0.2):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
@@ -25700,11 +25648,11 @@ snapshots:
optionalDependencies:
vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest-fetch-mock@0.4.5(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))):
vitest-fetch-mock@0.4.5(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -25733,7 +25681,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 24.12.2
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2)
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- jiti
- less
@@ -25778,37 +25726,7 @@ snapshots:
transitivePeerDependencies:
- msw
vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.2
'@vitest/mocker': 4.1.2(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.2
'@vitest/runner': 4.1.2
'@vitest/snapshot': 4.1.2
'@vitest/spy': 4.1.2
'@vitest/utils': 4.1.2
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.12.2
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.2
'@vitest/mocker': 4.1.2(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -25834,7 +25752,7 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@types/node': 25.5.0
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2)
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- msw
+5 -1
View File
@@ -26,6 +26,7 @@
"test": "vitest --config test/vitest.config.mjs",
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
@@ -62,6 +63,7 @@
"@react-email/components": "^1.0.0",
"@react-email/render": "^2.0.0",
"@socket.io/redis-adapter": "^8.3.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
@@ -94,10 +96,11 @@
"nestjs-cls": "^6.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"nestjs-zod": "^5.3.0",
"nodemailer": "^8.0.0",
"nestjs-zod": "^5.3.0",
"openid-client": "^6.3.3",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
"picomatch": "^4.0.2",
"postgres": "3.4.8",
"react": "^19.0.0",
@@ -154,6 +157,7 @@
"eslint-plugin-unicorn": "^64.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"node-gyp": "^12.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
+4
View File
@@ -1,3 +1,4 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
@@ -51,6 +52,9 @@ const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000;
+71 -1
View File
@@ -118,6 +118,7 @@ describe(AuthController.name, () => {
expect(service.login).toHaveBeenCalledWith(
expect.objectContaining({ email: 'admin@immich.app' }),
expect.anything(),
expect.anything(),
);
});
@@ -129,7 +130,49 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201);
expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything());
expect(service.login).toHaveBeenCalledWith(
expect.objectContaining({ email: 'admin@local' }),
expect.anything(),
expect.anything(),
);
});
it('should clear the link token cookie on successful login when it was present', async () => {
const loginResponse = mediumFactory.loginResponse();
service.login.mockResolvedValue(loginResponse);
const { status, headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201);
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
it('should clear the link token cookie when login fails', async () => {
service.login.mockRejectedValue(new Error('Incorrect email or password'));
const { headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({ name: 'admin', email: 'admin@local', password: 'wrong' });
const cookies = (headers['set-cookie'] as unknown as string[] | undefined)?.join('\n') ?? '';
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
it('should not set a link token cookie header when no link token was present', async () => {
const loginResponse = mediumFactory.loginResponse();
service.login.mockResolvedValue(loginResponse);
const { headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.send({ name: 'admin', email: 'admin@local', password: 'password' });
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
expect(cookies).not.toMatch(/immich_oauth_link_token=/);
});
it('should auth cookies on a secure connection', async () => {
@@ -170,6 +213,33 @@ describe(AuthController.name, () => {
});
});
describe('POST /auth/register', () => {
it('should clear the link token cookie on successful register', async () => {
const loginResponse = mediumFactory.loginResponse();
service.register.mockResolvedValue(loginResponse);
const { headers } = await request(ctx.getHttpServer())
.post('/auth/register')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({});
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
it('should clear the link token cookie when register fails', async () => {
service.register.mockRejectedValue(new Error('Missing OAuth link token'));
const { headers } = await request(ctx.getHttpServer())
.post('/auth/register')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({});
const cookies = (headers['set-cookie'] as unknown as string[] | undefined)?.join('\n') ?? '';
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
});
describe('POST /auth/logout', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/auth/logout');
+47 -9
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
@@ -34,19 +35,56 @@ export class AuthController {
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async login(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Body() loginCredential: LoginCredentialDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.login(loginCredential, loginDetails);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Password },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
try {
const body = await this.service.login(loginCredential, loginDetails, request.headers);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Password },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
} finally {
if (hadLinkCookie) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
}
}
@Post('register')
@Endpoint({
summary: 'Register via OAuth',
description: 'Create a new user from a pending OAuth link token (requires OAuth auto-register to be enabled).',
history: new HistoryBuilder().added('v2'),
})
async register(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
try {
const body = await this.service.register(loginDetails, request.headers);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
} finally {
if (hadLinkCookie) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
}
}
@Post('admin-sign-up')
+47 -24
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
@@ -9,11 +10,12 @@ import {
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthReLinkStartDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { AuthService, LoginDetails, OAuthLinkRequiredException } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Authentication)
@@ -73,33 +75,54 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.callback(dto, request.headers, loginDetails);
res.clearCookie(ImmichCookie.OAuthState);
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
let freshLinkCookieIssued = false;
try {
const body = await this.service.callback(dto, request.headers, loginDetails);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
} catch (error) {
if (error instanceof OAuthLinkRequiredException) {
respondWithCookie(res, null, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }],
});
freshLinkCookieIssued = true;
}
throw error;
} finally {
res.clearCookie(ImmichCookie.OAuthState);
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
if (hadLinkCookie && !freshLinkCookieIssued) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
}
}
@Post('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Post('relink-start')
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Link OAuth account',
description: 'Link an OAuth account to the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
summary: 'Start OAuth re-link',
description:
'Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.',
history: new HistoryBuilder().added('v2'),
})
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@Body() dto: OAuthCallbackDto,
): Promise<UserAdminResponseDto> {
return this.service.link(auth, dto, request.headers);
async startOAuthReLink(
@Body() dto: OAuthReLinkStartDto,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<void> {
await this.service.validateOAuthReLinkToken(dto.token);
respondWithCookie(res, null, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.OAuthLinkToken, value: dto.token }],
});
}
@Post('unlink')
@@ -6,6 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import {
OAuthReLinkTokenResponseDto,
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
@@ -137,6 +138,21 @@ export class UserAdminController {
return this.service.updatePreferences(auth, id, dto);
}
@Post(':id/oauth-relink-token')
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
@Endpoint({
summary: 'Issue an OAuth re-link token',
description:
'Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.',
history: new HistoryBuilder().added('v2'),
})
createOAuthReLinkTokenAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
): Promise<OAuthReLinkTokenResponseDto> {
return this.service.createOAuthReLinkToken(auth, id);
}
@Post(':id/restore')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@HttpCode(HttpStatus.OK)
+3 -1
View File
@@ -18,7 +18,7 @@ import {
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
@@ -277,6 +277,8 @@ export type AssetFace = {
isVisible: boolean;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
+12 -1
View File
@@ -1,6 +1,6 @@
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
@@ -172,6 +172,17 @@ export const Endpoint = ({ history, ...options }: EndpointOptions) => {
return applyDecorators(...decorators);
};
export type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return ApiProperty({ ...options, ...extensions });
};
type HistoryEntry = {
version: string;
state: ApiState | 'Added' | 'Updated';
@@ -3,6 +3,7 @@ import z from 'zod';
export enum AssetMediaStatus {
CREATED = 'created',
REPLACED = 'replaced',
DUPLICATE = 'duplicate',
}
+7
View File
@@ -128,6 +128,12 @@ const OAuthBackchannelLogoutSchema = z
.object({ logout_token: z.string().describe('OAuth logout token') })
.meta({ id: 'OAuthBackchannelLogoutDto' });
const OAuthReLinkStartSchema = z
.object({
token: z.string().describe('Plaintext OAuth re-link token issued by an administrator'),
})
.meta({ id: 'OAuthReLinkStartDto' });
const AuthStatusResponseSchema = z
.object({
pinCode: z.boolean().describe('Has PIN code set'),
@@ -152,4 +158,5 @@ export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
export class OAuthReLinkStartDto extends createZodDto(OAuthReLinkStartSchema) {}
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
+1
View File
@@ -132,6 +132,7 @@ const ServerFeaturesSchema = z
importFaces: z.boolean().describe('Whether face import is enabled'),
oauth: z.boolean().describe('Whether OAuth is enabled'),
oauthAutoLaunch: z.boolean().describe('Whether OAuth auto-launch is enabled'),
oauthAutoRegister: z.boolean().describe('Whether OAuth auto-register is enabled'),
passwordLogin: z.boolean().describe('Whether password login is enabled'),
sidecar: z.boolean().describe('Whether sidecar files are supported'),
search: z.boolean().describe('Whether search is enabled'),
+9
View File
@@ -121,6 +121,15 @@ const UserAdminDeleteSchema = z
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
const OAuthReLinkTokenResponseSchema = z
.object({
token: z.string().describe('Single-use token; deliver to the user via /auth/link?token=<token>'),
expiresAt: isoDatetimeToDate.describe('Token expiration'),
})
.meta({ id: 'OAuthReLinkTokenResponseDto' });
export class OAuthReLinkTokenResponseDto extends createZodDto(OAuthReLinkTokenResponseSchema) {}
const UserAdminResponseSchema = UserResponseSchema.extend({
storageLabel: z.string().nullable().describe('Storage label'),
shouldChangePassword: z.boolean().describe('Require password change on next login'),
+95
View File
@@ -5,6 +5,8 @@ export enum AuthType {
OAuth = 'oauth',
}
export const AuthTypeSchema = z.enum(AuthType).describe('Auth type').meta({ id: 'AuthType' });
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
@@ -13,8 +15,11 @@ export enum ImmichCookie {
SharedLinkToken = 'immich_shared_link_token',
OAuthState = 'immich_oauth_state',
OAuthCodeVerifier = 'immich_oauth_code_verifier',
OAuthLinkToken = 'immich_oauth_link_token',
}
export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' });
export enum ImmichHeader {
ApiKey = 'x-api-key',
UserToken = 'x-immich-user-token',
@@ -25,6 +30,8 @@ export enum ImmichHeader {
Cid = 'x-immich-cid',
}
export const ImmichHeaderSchema = z.enum(ImmichHeader).describe('Immich header').meta({ id: 'ImmichHeader' });
export enum ImmichQuery {
SharedLinkKey = 'key',
SharedLinkSlug = 'slug',
@@ -32,6 +39,8 @@ export enum ImmichQuery {
SessionKey = 'sessionKey',
}
export const ImmichQuerySchema = z.enum(ImmichQuery).describe('Immich query').meta({ id: 'ImmichQuery' });
export enum AssetType {
Image = 'IMAGE',
Video = 'VIDEO',
@@ -48,6 +57,11 @@ export enum ChecksumAlgorithm {
sha1Path = 'sha1-path',
}
export const ChecksumAlgorithmSchema = z
.enum(ChecksumAlgorithm)
.describe('Checksum algorithm')
.meta({ id: 'ChecksumAlgorithmEnum' });
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@@ -59,6 +73,8 @@ export enum AssetFileType {
EncodedVideo = 'encoded_video',
}
export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Asset file type').meta({ id: 'AssetFileType' });
export enum AlbumUserRole {
Editor = 'editor',
Viewer = 'viewer',
@@ -298,6 +314,8 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export const PermissionSchema = z.enum(Permission).describe('Permission').meta({ id: 'Permission' });
export enum SharedLinkType {
Album = 'ALBUM',
@@ -334,6 +352,11 @@ export enum SystemMetadataKey {
License = 'license',
}
export const SystemMetadataKeySchema = z
.enum(SystemMetadataKey)
.describe('System metadata key')
.meta({ id: 'SystemMetadataKey' });
export enum UserMetadataKey {
Preferences = 'preferences',
License = 'license',
@@ -349,6 +372,11 @@ export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
export const AssetMetadataKeySchema = z
.enum(AssetMetadataKey)
.describe('Asset metadata key')
.meta({ id: 'AssetMetadataKey' });
export enum UserAvatarColor {
Primary = 'primary',
Pink = 'pink',
@@ -381,6 +409,8 @@ export enum AssetStatus {
Deleted = 'deleted',
}
export const AssetStatusSchema = z.enum(AssetStatus).describe('Asset status').meta({ id: 'AssetStatus' });
export enum SourceType {
MachineLearning = 'machine-learning',
Exif = 'exif',
@@ -405,14 +435,20 @@ export enum AssetPathType {
EncodedVideo = 'encoded_video',
}
export const AssetPathTypeSchema = z.enum(AssetPathType).describe('Asset path type').meta({ id: 'AssetPathType' });
export enum PersonPathType {
Face = 'face',
}
export const PersonPathTypeSchema = z.enum(PersonPathType).describe('Person path type').meta({ id: 'PersonPathType' });
export enum UserPathType {
Profile = 'profile',
}
export const UserPathTypeSchema = z.enum(UserPathType).describe('User path type').meta({ id: 'UserPathType' });
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy {
@@ -435,6 +471,11 @@ export enum TranscodeTarget {
All = 'ALL',
}
export const TranscodeTargetSchema = z
.enum(TranscodeTarget)
.describe('Transcode target')
.meta({ id: 'TranscodeTarget' });
export enum VideoCodec {
H264 = 'h264',
Hevc = 'hevc',
@@ -516,6 +557,11 @@ export enum RawExtractedFormat {
Jxl = 'jxl',
}
export const RawExtractedFormatSchema = z
.enum(RawExtractedFormat)
.describe('Raw extracted format')
.meta({ id: 'RawExtractedFormat' });
export enum LogLevel {
Verbose = 'verbose',
Debug = 'debug',
@@ -541,25 +587,38 @@ export enum ApiCustomExtension {
State = 'x-immich-state',
}
export const ApiCustomExtensionSchema = z
.enum(ApiCustomExtension)
.describe('API custom extension')
.meta({ id: 'ApiCustomExtension' });
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
SharedRoute = 'shared_route',
ApiKeySecurity = 'api_key',
EventConfig = 'event_config',
JobConfig = 'job_config',
TelemetryEnabled = 'telemetry_enabled',
}
export const MetadataKeySchema = z.enum(MetadataKey).describe('Metadata key').meta({ id: 'MetadataKey' });
export enum RouteKey {
Asset = 'assets',
User = 'users',
}
export const RouteKeySchema = z.enum(RouteKey).describe('Route key').meta({ id: 'RouteKey' });
export enum CacheControl {
PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache',
None = 'none',
}
export const CacheControlSchema = z.enum(CacheControl).describe('Cache control').meta({ id: 'CacheControl' });
export enum ImmichEnvironment {
Development = 'development',
Testing = 'testing',
@@ -577,6 +636,8 @@ export enum ImmichWorker {
Microservices = 'microservices',
}
export const ImmichWorkerSchema = z.enum(ImmichWorker).describe('Immich worker').meta({ id: 'ImmichWorker' });
export enum ImmichTelemetry {
Host = 'host',
Api = 'api',
@@ -585,6 +646,11 @@ export enum ImmichTelemetry {
Job = 'job',
}
export const ImmichTelemetrySchema = z
.enum(ImmichTelemetry)
.describe('Immich telemetry')
.meta({ id: 'ImmichTelemetry' });
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
@@ -596,6 +662,11 @@ export enum ExifOrientation {
Rotate270CW = 8,
}
export const ExifOrientationSchema = z
.enum(ExifOrientation)
.describe('EXIF orientation')
.meta({ id: 'ExifOrientation' });
export enum DatabaseExtension {
Cube = 'cube',
EarthDistance = 'earthdistance',
@@ -604,6 +675,11 @@ export enum DatabaseExtension {
VectorChord = 'vchord',
}
export const DatabaseExtensionSchema = z
.enum(DatabaseExtension)
.describe('Database extension')
.meta({ id: 'DatabaseExtension' });
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
@@ -615,6 +691,11 @@ export enum BootstrapEventPriority {
SystemConfig = 100,
}
export const BootstrapEventPrioritySchema = z
.enum(BootstrapEventPriority)
.describe('Bootstrap event priority')
.meta({ id: 'BootstrapEventPriority' });
export enum QueueName {
ThumbnailGeneration = 'thumbnailGeneration',
MetadataExtraction = 'metadataExtraction',
@@ -753,15 +834,21 @@ export enum JobStatus {
Skipped = 'skipped',
}
export const JobStatusSchema = z.enum(JobStatus).describe('Job status').meta({ id: 'JobStatus' });
export enum QueueCleanType {
Failed = 'failed',
}
export const QueueCleanTypeSchema = z.enum(QueueCleanType).describe('Queue clean type').meta({ id: 'QueueCleanType' });
export enum VectorIndex {
Clip = 'clip_index',
Face = 'face_index',
}
export const VectorIndexSchema = z.enum(VectorIndex).describe('Vector index').meta({ id: 'VectorIndex' });
export enum DatabaseLock {
GeodataImport = 100,
Migrations = 200,
@@ -779,6 +866,8 @@ export enum DatabaseLock {
VersionCheck = 800,
}
export const DatabaseLockSchema = z.enum(DatabaseLock).describe('Database lock').meta({ id: 'DatabaseLock' });
export enum MaintenanceAction {
Start = 'start',
End = 'end',
@@ -795,6 +884,8 @@ export enum ExitCode {
AppRestart = 7,
}
export const ExitCodeSchema = z.enum(ExitCode).describe('Exit code').meta({ id: 'ExitCode' });
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
@@ -953,6 +1044,8 @@ export enum CronJob {
VersionCheck = 'VersionCheck',
}
export const CronJobSchema = z.enum(CronJob).describe('Cron job').meta({ id: 'CronJob' });
export enum ApiTag {
Activities = 'Activities',
Albums = 'Albums',
@@ -993,6 +1086,8 @@ export enum ApiTag {
Workflows = 'Workflows',
}
export const ApiTagSchema = z.enum(ApiTag).describe('API tag').meta({ id: 'ApiTag' });
export enum PluginContext {
Asset = 'asset',
Album = 'album',
+2
View File
@@ -25,6 +25,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -78,6 +79,7 @@ export const repositories = [
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthLinkTokenRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,
@@ -33,6 +33,12 @@ export interface ReverseGeocodeResult {
city: string | null;
}
export interface MapMarker extends ReverseGeocodeResult {
id: string;
lat: number;
lon: number;
}
interface MapDB extends DB {
geodata_places_tmp: GeodataPlacesTable;
naturalearth_countries_tmp: NaturalEarthCountriesTable;
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/schema';
import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table';
@Injectable()
export class OAuthLinkTokenRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<OAuthLinkTokenTable>) {
return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
}
getByToken(token: Buffer) {
return this.db
.selectFrom('oauth_link_token')
.selectAll()
.where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate())
.executeTakeFirst();
}
consumeToken(token: Buffer, kind: 'callback' | 'admin' | 'any' = 'any') {
let query = this.db
.deleteFrom('oauth_link_token')
.where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate());
if (kind === 'callback') {
query = query.where('oauthSub', 'is not', null);
} else if (kind === 'admin') {
query = query.where('oauthSub', 'is', null);
}
return query.returningAll().executeTakeFirst();
}
async cleanup() {
const result = await this.db
.deleteFrom('oauth_link_token')
.where('expiresAt', '<=', DateTime.now().toJSDate())
.execute();
return Number(result[0]?.numDeletedRows ?? 0);
}
}
+4
View File
@@ -50,6 +50,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table';
import { OcrSearchTable } from 'src/schema/tables/ocr-search.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
@@ -108,6 +109,7 @@ export class ImmichDatabase {
MoveTable,
NaturalEarthCountriesTable,
NotificationTable,
OAuthLinkTokenTable,
OcrSearchTable,
PartnerAuditTable,
PartnerTable,
@@ -210,6 +212,8 @@ export interface DB {
notification: NotificationTable;
oauth_link_token: OAuthLinkTokenTable;
move_history: MoveTable;
naturalearth_countries: NaturalEarthCountriesTable;
@@ -0,0 +1,23 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
CREATE TABLE "oauth_link_token" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"token" bytea NOT NULL,
"oauthSub" varchar,
"oauthSid" varchar,
"email" varchar NOT NULL,
"profile" jsonb,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
);
`.execute(db);
await sql`ALTER TABLE "oauth_link_token" ADD CONSTRAINT "oauth_link_token_pkey" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "oauth_link_token_token_idx" ON "oauth_link_token" ("token")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE IF EXISTS "oauth_link_token";`.execute(db);
}
@@ -0,0 +1,36 @@
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
export type OAuthLinkTokenProfile = {
name: string;
storageLabel: string | null;
storageQuotaInGiB: number | null;
isAdmin: boolean;
picture: string | null;
};
@Table({ name: 'oauth_link_token' })
export class OAuthLinkTokenTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'bytea', index: true })
token!: Buffer;
@Column({ nullable: true })
oauthSub!: string | null;
@Column({ nullable: true })
oauthSid!: string | null;
@Column()
email!: string;
@Column({ type: 'jsonb', nullable: true })
profile!: OAuthLinkTokenProfile | null;
@Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
+575 -235
View File
@@ -4,7 +4,7 @@ import { SALT_ROUNDS } from 'src/constants';
import { UserAdmin } from 'src/database';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service';
import { AuthService, OAuthLinkRequiredException } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory';
@@ -50,13 +50,13 @@ describe(AuthService.name, () => {
it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -64,7 +64,7 @@ describe(AuthService.name, () => {
it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -75,7 +75,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
await expect(sut.login(dto, loginDetails, {})).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id,
userEmail: user.email,
@@ -88,6 +88,312 @@ describe(AuthService.name, () => {
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an OAuth account when link token cookie is present', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, expect.objectContaining({ oauthId: 'oauth-sub-123' }));
});
it('should propagate oauthSid from link token to the session', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: 'idp-sid-456',
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-456' }));
});
it('should silently fall back to normal login when the link token is invalid or expired', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(null as any);
await expect(
sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=bad-token' }),
).resolves.toMatchObject({ userId: user.id });
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: null }));
});
it('should reject when the link token points to a sub already linked to another user', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const otherUser = UserFactory.create({ oauthId: 'oauth-sub-123' });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(otherUser);
await expect(sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' })).rejects.toThrow(
'This OAuth account has already been linked to another user.',
);
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should sanitize the storage label when linking from an OAuth profile', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: '../evil/path',
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
const updateCall = mocks.user.update.mock.calls[0][1];
expect(updateCall.storageLabel).not.toContain('/');
expect(updateCall.storageLabel).not.toContain('.');
});
});
describe('register', () => {
it('should throw if auto-register is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
'OAuth auto-register is disabled',
);
});
it('should throw if link token cookie is missing', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
await expect(sut.register(loginDetails, {})).rejects.toThrow('Missing OAuth link token');
});
it('should throw if the sub is already linked', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'new@immich.cloud',
profile: {
name: 'New User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-sub-123' }));
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
'This OAuth account has already been linked to another user',
);
});
it('should create a user from the link token and apply the profile', async () => {
const newUser = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: 'idp-sid',
email: 'new@immich.cloud',
profile: {
name: 'New User',
storageLabel: 'shiny',
storageQuotaInGiB: 5,
isAdmin: true,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(newUser);
mocks.user.update.mockResolvedValue(newUser);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
expect(mocks.user.create).toHaveBeenCalledWith(
expect.objectContaining({ email: 'new@immich.cloud', name: 'New User', isAdmin: true }),
);
expect(mocks.user.update).toHaveBeenCalledWith(
newUser.id,
expect.objectContaining({
oauthId: 'oauth-sub-123',
storageLabel: 'shiny',
quotaSizeInBytes: 5 * 1024 * 1024 * 1024,
isAdmin: true,
}),
);
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid' }));
});
it('should allow the first OAuth admin to bootstrap the instance', async () => {
const newUser = UserFactory.create({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'first@immich.cloud',
profile: {
name: 'First Admin',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: true,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(newUser);
mocks.user.update.mockResolvedValue(newUser);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
expect(mocks.user.getAdmin).not.toHaveBeenCalled();
});
it('should reject a non-admin OAuth register when no admin exists yet', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'first@immich.cloud',
profile: {
name: 'Regular User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(void 0);
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
'The first registered account must the administrator.',
);
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should sanitize the storage label on register', async () => {
const newUser = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'new@immich.cloud',
profile: {
name: 'New User',
storageLabel: '../sneaky',
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(newUser);
mocks.user.update.mockResolvedValue(newUser);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
const updateCall = mocks.user.update.mock.calls[0][1];
expect(updateCall.storageLabel).not.toContain('/');
expect(updateCall.storageLabel).not.toContain('.');
});
});
describe('changePassword', () => {
@@ -686,69 +992,12 @@ describe(AuthService.name, () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
const user = UserFactory.create();
it('should create a link token when the oauth sub is not yet linked', async () => {
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'idp-sid-789' });
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
@@ -756,31 +1005,34 @@ describe(AuthService.name, () => {
{},
loginDetails,
),
).rejects.toThrow(BadRequestException);
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should allow auto registering by default', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ oauthSub: profile.sub, oauthSid: 'idp-sid-789', email: profile.email }),
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(mocks.user.create).toHaveBeenCalledTimes(1);
});
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
it('should normalize the email from the OAuth profile before storing in the link token', async () => {
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@immich.cloud' }));
});
it('should throw an error if the OAuth profile does not have an email claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -824,19 +1076,20 @@ describe(AuthService.name, () => {
it('should use the default quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should infer name from given and family names', async () => {
@@ -844,18 +1097,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ name: 'Given Family' }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
});
it('should fallback to email when no username is provided', async () => {
@@ -863,18 +1117,19 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ name: profile.email }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
});
it('should ignore an invalid storage quota', async () => {
@@ -882,18 +1137,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should ignore a negative quota', async () => {
@@ -901,53 +1157,55 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 0 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
});
it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 5 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
});
it('should sync the profile picture', async () => {
@@ -1041,19 +1299,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: false }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
});
it('should create an admin user if the role claim is set to admin', async () => {
@@ -1061,18 +1319,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
});
it('should accept a custom role claim', async () => {
@@ -1082,77 +1341,158 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
);
});
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
describe('admin-issued re-link token', () => {
const reLinkRecord = {
id: 'token-id',
oauthSub: null,
oauthSid: 'idp-sid-new',
email: 'linked@immich.cloud',
profile: null,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
};
it('should relink to the user identified by the token when the new sub is unknown', async () => {
const targetUser = UserFactory.create({ email: 'linked@immich.cloud', oauthId: 'old-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
sid: 'idp-sid-new',
});
mocks.user.getByOAuthId.mockResolvedValueOnce(void 0).mockResolvedValueOnce(void 0);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
mocks.user.getByEmail.mockResolvedValue(targetUser);
mocks.user.update.mockResolvedValue({ ...targetUser, oauthId: 'new-sub' });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(targetUser.id, { oauthId: 'new-sub' });
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-new' }));
});
it('should reject when the token email no longer matches a user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(
sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
),
).rejects.toThrow('no longer exists');
});
it('should reject when the new sub is already linked to a different user', async () => {
const targetUser = UserFactory.create({ email: 'linked@immich.cloud' });
const other = UserFactory.create({ oauthId: 'new-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
});
mocks.user.getByOAuthId.mockResolvedValueOnce(void 0).mockResolvedValueOnce(other);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
mocks.user.getByEmail.mockResolvedValue(targetUser);
await expect(
sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
),
).rejects.toThrow('already been linked to another user');
});
it('should fall through to callback-issued link flow when the cookie is not an admin-issued token', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
// Cookie carries a callback-issued token; admin-typed consume returns nothing.
mocks.oauthLinkToken.consumeToken.mockResolvedValue(void 0);
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauthLinkToken.create).toHaveBeenCalled();
});
});
});
describe('link', () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.update.mockResolvedValue(user);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
describe('validateOAuthReLinkToken', () => {
it('should throw when OAuth is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('OAuth is not enabled');
});
it('should link an account and update the session with the oauthSid', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
it('should throw when the token does not exist', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.getByToken.mockResolvedValue(void 0);
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
});
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
it('should throw when the token is a callback-issued one (non-null sub)', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.getByToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'sub',
oauthSid: null,
email: 'e',
profile: null,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
});
it('should not link an already linked oauth.sub', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
sut.link(auth, { url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, {}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled();
it('should resolve when the token is valid and admin-issued', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.getByToken.mockResolvedValue({
id: 'token-id',
oauthSub: null,
oauthSid: null,
email: 'e',
profile: null,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
});
await expect(sut.validateOAuthReLinkToken('plain')).resolves.toBeUndefined();
});
});
+177 -88
View File
@@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import {
@@ -23,6 +25,7 @@ import {
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { OAuthLinkTokenProfile } from 'src/schema/tables/oauth-link-token.table';
import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
@@ -42,6 +45,15 @@ interface ClaimOptions<T> {
isValid: (value: unknown) => boolean;
}
export class OAuthLinkRequiredException extends ForbiddenException {
constructor(
public readonly userEmail: string,
public readonly oauthLinkToken: string,
) {
super({ message: 'oauth_account_link_required', userEmail });
}
}
export type ValidateRequest = {
headers: IncomingHttpHeaders;
queryParams: Record<string, string>;
@@ -56,7 +68,7 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
async login(dto: LoginCredentialDto, details: LoginDetails) {
async login(dto: LoginCredentialDto, details: LoginDetails, headers: IncomingHttpHeaders) {
const config = await this.getConfig({ withCache: false });
if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
@@ -75,7 +87,55 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Incorrect email or password');
}
return this.createLoginResponse(user, details);
let linkedOAuthSid: string | undefined;
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (linkTokenCookie) {
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
if (record && record.oauthSub !== null && record.profile !== null) {
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
if (duplicate && duplicate.id !== user.id) {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
user = await this.applyOAuthProfileToUser(user, { oauthSub: record.oauthSub, profile: record.profile });
linkedOAuthSid = record.oauthSid ?? undefined;
}
}
return this.createLoginResponse(user, details, linkedOAuthSid);
}
async register(details: LoginDetails, headers: IncomingHttpHeaders) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled || !oauth.autoRegister) {
throw new BadRequestException('OAuth auto-register is disabled');
}
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (!linkTokenCookie) {
throw new BadRequestException('Missing OAuth link token');
}
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
if (!record || record.oauthSub === null || record.profile === null) {
throw new BadRequestException('Invalid OAuth link token for registration');
}
const { oauthSub, profile } = record;
const existing = await this.userRepository.getByOAuthId(oauthSub);
if (existing) {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
this.logger.log(`Registering new user from OAuth: ${oauthSub}/${record.email}`);
const newUser = await this.createUser({
email: record.email,
name: profile.name,
isAdmin: profile.isAdmin,
});
const user = await this.applyOAuthProfileToUser(newUser, { oauthSub, profile });
return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
}
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
@@ -277,6 +337,19 @@ export class AuthService extends BaseService {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
async validateOAuthReLinkToken(plainToken: string) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const hashed = this.cryptoRepository.hashSha256(plainToken);
const record = await this.oauthLinkTokenRepository.getByToken(hashed);
if (!record || record.oauthSub !== null) {
throw new BadRequestException('Invalid or expired re-link token');
}
}
async authorize(dto: OAuthConfigDto) {
const { oauth } = await this.getConfig({ withCache: false });
@@ -316,71 +389,112 @@ export class AuthService extends BaseService {
codeVerifier,
);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
const user = await this.userRepository.getByOAuthId(profile.sub);
// link by email
if (!user && normalizedEmail) {
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
if (emailUser) {
if (emailUser.oauthId) {
throw new BadRequestException('User already exists, but is linked to another account.');
}
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
if (user) {
if (!user.profileImagePath && profile.picture) {
await this.syncProfilePicture(user, profile.picture);
}
return this.createLoginResponse(user, loginDetails, oauthSid);
}
const reLinkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (reLinkTokenCookie) {
const hashedCookie = this.cryptoRepository.hashSha256(reLinkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedCookie, 'admin');
if (record) {
return this.completeAdminIssuedReLink(record, profile.sub, oauthSid, loginDetails);
}
}
// register new user
if (!user) {
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
if (!normalizedEmail) {
throw new BadRequestException('OAuth profile does not have an email address');
}
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: (value: unknown): value is string => typeof value === 'string',
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
const role = this.getClaim<'admin' | 'user'>(profile, {
key: roleClaim,
default: 'user',
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
});
user = await this.createUser({
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
normalizedEmail,
email: normalizedEmail,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,
isAdmin: role === 'admin',
});
if (!normalizedEmail) {
throw new BadRequestException('OAuth profile does not have an email address');
}
if (!user.profileImagePath && profile.picture) {
await this.syncProfilePicture(user, profile.picture);
const resolvedProfile = this.resolveOAuthProfile(profile, normalizedEmail, oauth);
const plainToken = this.cryptoRepository.randomBytesAsText(32);
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
await this.oauthLinkTokenRepository.create({
token: hashedToken,
oauthSub: profile.sub,
oauthSid: oauthSid ?? null,
email: normalizedEmail,
profile: resolvedProfile,
expiresAt: DateTime.now().plus({ minutes: 10 }).toJSDate(),
});
throw new OAuthLinkRequiredException(normalizedEmail, plainToken);
}
private async completeAdminIssuedReLink(
record: { email: string },
newOAuthSub: string,
oauthSid: string | undefined,
loginDetails: LoginDetails,
) {
const targetUser = await this.userRepository.getByEmail(record.email);
if (!targetUser) {
throw new BadRequestException('The user for this re-link token no longer exists');
}
return this.createLoginResponse(user, loginDetails, oauthSid);
const duplicate = await this.userRepository.getByOAuthId(newOAuthSub);
if (duplicate && duplicate.id !== targetUser.id) {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
this.logger.log(`Completing admin-issued OAuth re-link for user ${targetUser.id}`);
const updated = await this.userRepository.update(targetUser.id, { oauthId: newOAuthSub });
return this.createLoginResponse(updated, loginDetails, oauthSid);
}
private resolveOAuthProfile(
profile: OAuthProfile,
normalizedEmail: string,
oauth: SystemConfig['oauth'],
): OAuthLinkTokenProfile {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: (value: unknown): value is string => typeof value === 'string',
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
const role = this.getClaim<'admin' | 'user'>(profile, {
key: roleClaim,
default: 'user',
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
});
return {
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
normalizedEmail,
storageLabel: storageLabel || null,
storageQuotaInGiB: storageQuota,
isAdmin: role === 'admin',
picture: profile.picture ?? null,
};
}
private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
const { profile } = record;
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null;
const updated = await this.userRepository.update(user.id, {
oauthId: record.oauthSub,
storageLabel,
quotaSizeInBytes: profile.storageQuotaInGiB === null ? null : profile.storageQuotaInGiB * HumanReadableSize.GiB,
isAdmin: profile.isAdmin,
});
if (!updated.profileImagePath && profile.picture) {
await this.syncProfilePicture(updated, profile.picture);
}
return updated;
}
private async syncProfilePicture(user: UserAdmin, url: string) {
@@ -406,36 +520,6 @@ export class AuthService extends BaseService {
}
}
async link(auth: AuthDto, dto: OAuthCallbackDto, headers: IncomingHttpHeaders): Promise<UserAdminResponseDto> {
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
}
const codeVerifier = dto.codeVerifier ?? this.getCookieCodeVerifier(headers);
if (!codeVerifier?.length) {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const {
profile: { sub: oauthId },
sid,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
return mapUserAdmin(user);
}
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
@@ -486,6 +570,11 @@ export class AuthService extends BaseService {
return cookies[ImmichCookie.OAuthCodeVerifier] || null;
}
private getCookieOAuthLinkToken(headers: IncomingHttpHeaders): string | null {
const cookies = parse(headers.cookie || '');
return cookies[ImmichCookie.OAuthLinkToken] || null;
}
async validateSharedLinkKey(key: string | string[]): Promise<AuthDto> {
key = Array.isArray(key) ? key[0] : key;
+3
View File
@@ -32,6 +32,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -88,6 +89,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthLinkTokenRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,
@@ -146,6 +148,7 @@ export class BaseService {
protected metadataRepository: MetadataRepository,
protected moveRepository: MoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthLinkTokenRepository: OAuthLinkTokenRepository,
protected oauthRepository: OAuthRepository,
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,
File diff suppressed because it is too large Load Diff
@@ -141,6 +141,7 @@ describe(ServerService.name, () => {
reverseGeocoding: true,
oauth: false,
oauthAutoLaunch: false,
oauthAutoRegister: true,
ocr: true,
passwordLogin: true,
search: true,
+1
View File
@@ -102,6 +102,7 @@ export class ServerService extends BaseService {
trash: trash.enabled,
oauth: oauth.enabled,
oauthAutoLaunch: oauth.autoLaunch,
oauthAutoRegister: oauth.autoRegister,
ocr: isOcrEnabled(machineLearning),
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,
@@ -20,6 +20,7 @@ describe('SessionService', () => {
describe('handleCleanup', () => {
it('should clean sessions', async () => {
mocks.session.cleanup.mockResolvedValue([]);
mocks.oauthLinkToken.cleanup.mockResolvedValue(0);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.Success);
});
});
+5
View File
@@ -24,6 +24,11 @@ export class SessionService extends BaseService {
this.logger.log(`Deleted ${sessions.length} expired session tokens`);
const expiredLinkTokens = await this.oauthLinkTokenRepository.cleanup();
if (expiredLinkTokens > 0) {
this.logger.debug(`Deleted ${expiredLinkTokens} expired OAuth link tokens`);
}
return JobStatus.Success;
}
@@ -5,6 +5,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
import { AuthFactory } from 'test/factories/auth.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe } from 'vitest';
@@ -165,6 +166,42 @@ describe(UserAdminService.name, () => {
});
});
describe('createOAuthReLinkToken', () => {
it('should throw when OAuth is not enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
});
it('should throw when the target user is missing', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.get.mockResolvedValueOnce(void 0);
await expect(sut.createOAuthReLinkToken(authStub.admin, 'missing')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
});
it('should create a token with null oauthSub and the target email', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
const result = await sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({
oauthSub: null,
oauthSid: null,
profile: null,
email: userStub.user1.email,
}),
);
expect(result.token).toEqual(expect.any(String));
expect(result.expiresAt).toBeInstanceOf(Date);
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
});
describe('restore', () => {
it('should throw error if user could not be found', async () => {
mocks.user.get.mockResolvedValue(void 0);
+25
View File
@@ -1,10 +1,12 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
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 {
OAuthReLinkTokenResponseDto,
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
@@ -137,6 +139,29 @@ export class UserAdminService extends BaseService {
return mapPreferences(getPreferences(metadata));
}
async createOAuthReLinkToken(auth: AuthDto, id: string): Promise<OAuthReLinkTokenResponseDto> {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const user = await this.findOrFail(id, {});
const plainToken = this.cryptoRepository.randomBytesAsText(32);
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
const expiresAt = DateTime.now().plus({ hours: 24 }).toJSDate();
await this.oauthLinkTokenRepository.create({
token: hashedToken,
oauthSub: null,
oauthSid: null,
email: user.email,
profile: null,
expiresAt,
});
this.logger.log(`Admin ${auth.user.id} issued an OAuth re-link token for user ${user.id}`);
return { token: plainToken, expiresAt };
}
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
await this.findOrFail(id, { withDeleted: false });
const metadata = await this.userRepository.getMetadata(id);
+5
View File
@@ -65,8 +65,13 @@ export interface DecodeToBufferOptions extends DecodeImageOptions {
}
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & DecodeToBufferOptions;
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };
export type GenerateThumbhashOptions = DecodeImageOptions;
export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo };
export interface GenerateThumbnailsOptions {
colorspace: string;
preview?: ImageOptions;
+2
View File
@@ -10,6 +10,8 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { DeepPartial } from 'src/types';
import { getKeysDeep, unsetDeep } from 'src/utils/misc';
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
type RepoDeps = {
configRepo: ConfigRepository;
metadataRepo: SystemMetadataRepository;
+49
View File
@@ -1,11 +1,60 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { Server as SocketIO } from 'socket.io';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
import { StorageFolder } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,
auth: MaintenanceAuthDto,
+85 -120
View File
@@ -91,14 +91,14 @@ export class BaseConfig implements VideoCodecSWConfig {
) {
const options = {
inputOptions: this.getBaseInputOptions(videoStream, format),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v', 'verbose'],
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
} as TranscodeCommand;
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) {
options.outputOptions.push('-vf', filters.join(','));
options.outputOptions.push(`-vf ${filters.join(',')}`);
}
}
@@ -121,40 +121,36 @@ export class BaseConfig implements VideoCodecSWConfig {
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
const options = [
'-c:v',
videoCodec,
'-c:a',
audioCodec,
`-c:v ${videoCodec}`,
`-c:a ${audioCodec}`,
// Makes a second pass moving the moov atom to the
// beginning of the file for improved playback speed.
'-movflags',
'faststart',
'-fps_mode',
'passthrough',
'-map',
`0:${videoStream.index}`,
'-map_metadata',
'-1',
'-movflags faststart',
'-fps_mode passthrough',
// explicitly selects the video stream instead of leaving it up to FFmpeg
`-map 0:${videoStream.index}`,
// Strip metadata like capture date, camera, and GPS
'-map_metadata -1',
];
if (audioStream) {
options.push('-map', `0:${audioStream.index}`);
options.push(`-map 0:${audioStream.index}`);
}
if (this.getBFrames() > -1) {
options.push('-bf', `${this.getBFrames()}`);
options.push(`-bf ${this.getBFrames()}`);
}
if (this.getRefs() > 0) {
options.push('-refs', `${this.getRefs()}`);
options.push(`-refs ${this.getRefs()}`);
}
if (this.getGopSize() > 0) {
options.push('-g', `${this.getGopSize()}`);
options.push(`-g ${this.getGopSize()}`);
}
if (
this.config.targetVideoCodec === VideoCodec.Hevc &&
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
) {
options.push('-tag:v', 'hvc1');
options.push('-tag:v hvc1');
}
return options;
@@ -177,32 +173,26 @@ export class BaseConfig implements VideoCodecSWConfig {
}
getPresetOptions() {
return ['-preset', this.config.preset];
return [`-preset ${this.config.preset}`];
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
if (this.eligibleForTwoPass()) {
return [
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-minrate',
`${bitrates.min}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
`-b:v ${bitrates.target}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
];
} else if (bitrates.max > 0) {
// -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate
return [
`-${this.useCQP() ? 'q:v' : 'crf'}`,
`${this.config.crf}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-bufsize',
`${bitrates.max * 2}${bitrates.unit}`,
`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.max * 2}${bitrates.unit}`,
];
} else {
return [`-${this.useCQP() ? 'q:v' : 'crf'}`, `${this.config.crf}`];
return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`];
}
}
@@ -214,7 +204,7 @@ export class BaseConfig implements VideoCodecSWConfig {
if (this.config.threads <= 0) {
return [];
}
return ['-threads', `${this.config.threads}`];
return [`-threads ${this.config.threads}`];
}
eligibleForTwoPass() {
@@ -405,8 +395,8 @@ export class ThumbnailConfig extends BaseConfig {
// skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
const options =
format?.formatName === 'mpegts'
? ['-sws_flags', 'accurate_rnd+full_chroma_int']
: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'];
? ['-sws_flags accurate_rnd+full_chroma_int']
: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
const metadataOverrides = [];
if (videoStream.colorPrimaries === 'reserved') {
@@ -423,14 +413,14 @@ export class ThumbnailConfig extends BaseConfig {
if (metadataOverrides.length > 0) {
// workaround for https://fftrac-bg.ffmpeg.org/ticket/11020
options.push('-bsf:v', `${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`);
options.push(`-bsf:v ${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`);
}
return options;
}
getBaseOutputOptions() {
return ['-fps_mode', 'vfr', '-frames:v', '1', '-update', '1'];
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
}
getFilterOptions(videoStream: VideoStreamInfo): string[] {
@@ -465,7 +455,7 @@ export class H264Config extends BaseConfig {
getOutputThreadOptions() {
const options = super.getOutputThreadOptions();
if (this.config.threads === 1) {
options.push('-x264-params', 'frame-threads=1:pools=none');
options.push('-x264-params frame-threads=1:pools=none');
}
return options;
@@ -476,7 +466,7 @@ export class HEVCConfig extends BaseConfig {
getOutputThreadOptions() {
const options = super.getOutputThreadOptions();
if (this.config.threads === 1) {
options.push('-x265-params', 'frame-threads=1:pools=none');
options.push('-x265-params frame-threads=1:pools=none');
}
return options;
@@ -487,7 +477,7 @@ export class VP9Config extends BaseConfig {
getPresetOptions() {
const speed = Math.min(this.getPresetIndex(), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
if (speed >= 0) {
return ['-cpu-used', `${speed}`];
return [`-cpu-used ${speed}`];
}
return [];
}
@@ -496,20 +486,17 @@ export class VP9Config extends BaseConfig {
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0 && this.eligibleForTwoPass()) {
return [
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-minrate',
`${bitrates.min}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
`-b:v ${bitrates.target}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
];
}
return [`-${this.useCQP() ? 'q:v' : 'crf'}`, `${this.config.crf}`, '-b:v', `${bitrates.max}${bitrates.unit}`];
return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`];
}
getOutputThreadOptions() {
return ['-row-mt', '1', ...super.getOutputThreadOptions()];
return ['-row-mt 1', ...super.getOutputThreadOptions()];
}
eligibleForTwoPass() {
@@ -525,13 +512,13 @@ export class AV1Config extends BaseConfig {
getPresetOptions() {
const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8
if (speed >= 0) {
return ['-preset', `${speed}`];
return [`-preset ${speed}`];
}
return [];
}
getBitrateOptions() {
const options = ['-crf', `${this.config.crf}`];
const options = [`-crf ${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
const svtparams = [];
if (this.config.threads > 0) {
@@ -541,7 +528,7 @@ export class AV1Config extends BaseConfig {
svtparams.push(`mbr=${bitrates.max}${bitrates.unit}`);
}
if (svtparams.length > 0) {
options.push('-svtav1-params', svtparams.join(':'));
options.push(`-svtav1-params ${svtparams.join(':')}`);
}
return options;
}
@@ -565,27 +552,23 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
}
getBaseInputOptions() {
return ['-init_hw_device', `cuda=cuda:${this.device}`, '-filter_hw_device', 'cuda'];
return [`-init_hw_device cuda=cuda:${this.device}`, '-filter_hw_device cuda'];
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = [
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
'-tune',
'hq',
'-qmin',
'0',
'-rc-lookahead',
'20',
'-i_qfactor',
'0.75',
'-tune hq',
'-qmin 0',
'-rc-lookahead 20',
'-i_qfactor 0.75',
...super.getBaseOutputOptions(target, videoStream, audioStream),
];
if (this.getBFrames() > 0) {
options.push('-b_ref_mode', 'middle', '-b_qfactor', '1.1');
options.push('-b_ref_mode middle', '-b_qfactor 1.1');
}
if (this.config.temporalAQ) {
options.push('-temporal-aq', '1');
options.push('-temporal-aq 1');
}
return options;
}
@@ -606,33 +589,26 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
return [];
}
presetIndex = 7 - Math.min(6, presetIndex); // map to p1-p7; p7 is the highest quality, so reverse index
return ['-preset', `p${presetIndex}`];
return [`-preset p${presetIndex}`];
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0 && this.config.twoPass) {
return [
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-bufsize',
`${bitrates.target}${bitrates.unit}`,
'-multipass',
'2',
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
'-multipass 2',
];
} else if (bitrates.max > 0) {
return [
'-cq:v',
`${this.config.crf}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-bufsize',
`${bitrates.target}${bitrates.unit}`,
`-cq:v ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
];
} else {
return ['-cq:v', `${this.config.crf}`];
return [`-cq:v ${this.config.crf}`];
}
}
@@ -651,7 +627,7 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
getBaseInputOptions() {
return ['-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-noautorotate', ...this.getInputThreadOptions()];
return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()];
}
getFilterOptions(videoStream: VideoStreamInfo) {
@@ -688,7 +664,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
}
getInputThreadOptions() {
return ['-threads', '1'];
return [`-threads 1`];
}
getOutputThreadOptions() {
@@ -698,14 +674,14 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
export class QsvSwDecodeConfig extends BaseHWConfig {
getBaseInputOptions() {
return ['-init_hw_device', `qsv=hw,child_device=${this.device}`, '-filter_hw_device', 'hw'];
return [`-init_hw_device qsv=hw,child_device=${this.device}`, '-filter_hw_device hw'];
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = super.getBaseOutputOptions(target, videoStream, audioStream);
// VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a
if (this.config.targetVideoCodec === VideoCodec.Vp9) {
options.push('-low_power', '1');
options.push('-low_power 1');
}
return options;
}
@@ -725,14 +701,14 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
return [];
}
presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
return ['-preset', `${presetIndex}`];
return [`-preset ${presetIndex}`];
}
getBitrateOptions() {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);
}
return options;
}
@@ -768,15 +744,11 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
getBaseInputOptions() {
return [
'-hwaccel',
'qsv',
'-hwaccel_output_format',
'qsv',
'-async_depth',
'4',
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-noautorotate',
'-qsv_device',
this.device,
`-qsv_device ${this.device}`,
...this.getInputThreadOptions(),
];
}
@@ -819,13 +791,13 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
}
getInputThreadOptions() {
return ['-threads', '1'];
return [`-threads 1`];
}
}
export class VaapiSwDecodeConfig extends BaseHWConfig {
getBaseInputOptions() {
return ['-init_hw_device', `vaapi=accel:${this.device}`, '-filter_hw_device', 'accel'];
return [`-init_hw_device vaapi=accel:${this.device}`, '-filter_hw_device accel'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
@@ -844,7 +816,7 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
return [];
}
presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
return ['-compression_level', `${presetIndex}`];
return [`-compression_level ${presetIndex}`];
}
getBitrateOptions() {
@@ -852,25 +824,21 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
const options = [];
if (this.config.targetVideoCodec === VideoCodec.Vp9) {
options.push('-bsf:v', 'vp9_raw_reorder,vp9_superframe');
options.push('-bsf:v vp9_raw_reorder,vp9_superframe');
}
// VAAPI doesn't allow setting both quality and max bitrate
if (bitrates.max > 0) {
options.push(
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-minrate',
`${bitrates.min}${bitrates.unit}`,
'-rc_mode',
'3',
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
'-rc_mode 3',
); // variable bitrate
} else if (this.useCQP()) {
options.push('-qp:v', `${this.config.crf}`, '-global_quality:v', `${this.config.crf}`, '-rc_mode', '1');
options.push(`-qp:v ${this.config.crf}`, `-global_quality:v ${this.config.crf}`, '-rc_mode 1');
} else {
options.push('-global_quality:v', `${this.config.crf}`, '-rc_mode', '4');
options.push(`-global_quality:v ${this.config.crf}`, '-rc_mode 4');
}
return options;
@@ -888,13 +856,10 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
getBaseInputOptions() {
return [
'-hwaccel',
'vaapi',
'-hwaccel_output_format',
'vaapi',
'-hwaccel vaapi',
'-hwaccel_output_format vaapi',
'-noautorotate',
'-hwaccel_device',
this.device,
`-hwaccel_device ${this.device}`,
...this.getInputThreadOptions(),
];
}
@@ -937,7 +902,7 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
}
getInputThreadOptions() {
return ['-threads', '1'];
return [`-threads 1`];
}
}
@@ -954,11 +919,11 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
switch (this.config.targetVideoCodec) {
case VideoCodec.H264: {
// from ffmpeg_mpp help, commonly referred to as H264 level 5.1
return ['-level', '51'];
return ['-level 51'];
}
case VideoCodec.Hevc: {
// from ffmpeg_mpp help, commonly referred to as HEVC level 5.1
return ['-level', '153'];
return ['-level 153'];
}
default: {
throw new Error(`Incompatible video codec for RKMPP: ${this.config.targetVideoCodec}`);
@@ -970,10 +935,10 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
const bitrate = this.getMaxBitrateValue();
if (bitrate > 0) {
// -b:v specifies max bitrate, average bitrate is derived automatically...
return ['-rc_mode', 'AVBR', '-b:v', `${bitrate}${this.getBitrateUnit()}`];
return ['-rc_mode AVBR', `-b:v ${bitrate}${this.getBitrateUnit()}`];
}
// use CRF value as QP value
return ['-rc_mode', 'CQP', '-qp_init', `${this.config.crf}`];
return ['-rc_mode CQP', `-qp_init ${this.config.crf}`];
}
getSupportedCodecs() {
@@ -987,7 +952,7 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
getBaseInputOptions() {
return ['-hwaccel', 'rkmpp', '-hwaccel_output_format', 'drm_prime', '-afbc', 'rga', '-noautorotate'];
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
+1
View File
@@ -18,6 +18,7 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
[ImmichCookie.MaintenanceToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
[ImmichCookie.OAuthState]: defaults,
[ImmichCookie.OAuthCodeVerifier]: defaults,
[ImmichCookie.OAuthLinkToken]: { ...defaults, maxAge: Duration.fromObject({ minutes: 10 }).toMillis() },
// no httpOnly so that the client can know the auth state
[ImmichCookie.IsAuthenticated]: { ...defaults, httpOnly: false },
[ImmichCookie.SharedLinkToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
+52
View File
@@ -1,4 +1,5 @@
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { SharedLinkType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
@@ -69,6 +70,23 @@ export const sharedLinkStub = {
album: null,
slug: null,
}),
readonlyNoExif: Object.freeze({
id: '123',
userId: authStub.admin.user.id,
key: sharedLinkBytes,
type: SharedLinkType.Individual,
createdAt: today,
expiresAt: tomorrow,
allowUpload: false,
allowDownload: false,
showExif: false,
description: null,
password: null,
assets: [],
albumId: null,
album: null,
slug: null,
}),
passwordRequired: Object.freeze({
id: '123',
userId: authStub.admin.user.id,
@@ -87,3 +105,37 @@ export const sharedLinkStub = {
album: null,
}),
};
export const sharedLinkResponseStub = {
valid: Object.freeze<SharedLinkResponseDto>({
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today,
description: null,
password: null,
expiresAt: tomorrow,
id: '123',
key: sharedLinkBytes.toString('base64url'),
showMetadata: true,
type: SharedLinkType.Album,
userId: 'admin_id',
slug: null,
}),
expired: Object.freeze<SharedLinkResponseDto>({
album: undefined,
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today,
description: null,
password: null,
expiresAt: yesterday,
id: '123',
key: sharedLinkBytes.toString('base64url'),
showMetadata: true,
type: SharedLinkType.Album,
userId: 'admin_id',
slug: null,
}),
};
+45
View File
@@ -67,3 +67,48 @@ export const errorDto = {
correlationId: expect.any(String),
},
};
export const signupResponseDto = {
admin: {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
license: null,
profileChangedAt: expect.any(String),
},
};
export const loginResponseDto = {
admin: {
accessToken: expect.any(String),
name: 'Immich Admin',
isAdmin: true,
profileImagePath: '',
shouldChangePassword: true,
userEmail: 'admin@immich.cloud',
userId: expect.any(String),
},
};
export const deviceDto = {
current: {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
deviceOS: '',
deviceType: '',
},
};
@@ -77,7 +77,7 @@ describe(AuthService.name, () => {
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password: 'wrong-password' };
await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password');
await expect(sut.login(dto, mediumFactory.loginDetails(), {})).rejects.toThrow('Incorrect email or password');
});
it('should accept a correct password and return a login response', async () => {
@@ -87,7 +87,7 @@ describe(AuthService.name, () => {
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password };
await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({
await expect(sut.login(dto, mediumFactory.loginDetails(), {})).resolves.toEqual({
accessToken: expect.any(String),
isAdmin: user.isAdmin,
isOnboarded: false,
@@ -147,7 +147,7 @@ describe(AuthService.name, () => {
expect((response as any).password).not.toBeDefined();
await expect(
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()),
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails(), {}),
).resolves.toBeDefined();
});
+41
View File
@@ -43,6 +43,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -239,6 +240,7 @@ export type ServiceOverrides = {
metadata: MetadataRepository;
move: MoveRepository;
notification: NotificationRepository;
oauthLinkToken: OAuthLinkTokenRepository;
ocr: OcrRepository;
oauth: OAuthRepository;
partner: PartnerRepository;
@@ -321,6 +323,7 @@ export const getMocks = () => {
move: automock(MoveRepository, { strict: false }),
notification: automock(NotificationRepository),
ocr: automock(OcrRepository, { strict: false }),
oauthLinkToken: automock(OAuthLinkTokenRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: automock(PersonRepository, { strict: false }),
@@ -387,6 +390,7 @@ export const newTestService = <T extends BaseService>(
overrides.metadata || (mocks.metadata as As<MetadataRepository>),
overrides.move || (mocks.move as As<MoveRepository>),
overrides.notification || (mocks.notification as As<NotificationRepository>),
overrides.oauthLinkToken || (mocks.oauthLinkToken as As<OAuthLinkTokenRepository>),
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),
@@ -531,6 +535,43 @@ export const mockDuplex =
return duplex;
};
export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const stdoutStream = new Readable({
read() {
this.push(stdout); // write mock data to stdout
this.push(null); // end stream
},
});
return {
stdout: stdoutStream,
stderr: new Readable({
read() {
this.push(stderr); // write mock data to stderr
this.push(null); // end stream
},
}),
stdin: new Writable({
write(chunk, encoding, callback) {
callback();
},
}),
exitCode,
on: vitest.fn((event, callback: any) => {
if (event === 'close') {
stdoutStream.once('end', () => callback(0));
}
if (event === 'error' && error) {
stdoutStream.once('end', () => callback(error));
}
if (event === 'exit') {
stdoutStream.once('end', () => callback(exitCode));
}
}),
kill: vitest.fn(),
} as unknown as ChildProcessWithoutNullStreams;
});
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
for (const item of items) {
await Promise.resolve();
+1 -1
View File
@@ -102,7 +102,7 @@
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.55.1",
"svelte-check": "^4.4.6",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.0",
@@ -37,7 +37,6 @@
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
@@ -53,6 +52,7 @@
let { asset, currentAlbum = null }: Props = $props();
let showEditFaces = $derived(assetViewerManager.isEditFacesPanelOpen);
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
@@ -118,385 +118,379 @@
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
};
onDestroy(() => {
assetViewerManager.closeEditFacesPanel();
});
</script>
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
{#if !assetViewerManager.isEditFacesPanelOpen}
<section class="relative p-2">
<div class="flex place-items-center gap-2">
<IconButton
icon={mdiClose}
aria-label={$t('close')}
onclick={() => assetViewerManager.closeDetailPanel()}
shape="round"
color="secondary"
variant="ghost"
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
</div>
<section class="relative p-2">
<div class="flex place-items-center gap-2">
<IconButton
icon={mdiClose}
aria-label={$t('close')}
onclick={() => assetViewerManager.closeDetailPanel()}
shape="round"
color="secondary"
variant="ghost"
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
</div>
{#if asset.isOffline}
<section class="px-4 py-4">
<div role="alert">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
{$t('asset_offline')}
</div>
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
{#if authManager.authenticated && authManager.user.isAdmin}
{$t('admin.asset_offline_description')}
{:else}
{$t('asset_offline_description')}
{/if}
</p>
</div>
<div class="rounded-b bg-red-500 px-4 py-2 text-white text-sm">
<p>{asset.originalPath}</p>
</div>
{#if asset.isOffline}
<section class="px-4 py-4">
<div role="alert">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
{$t('asset_offline')}
</div>
</section>
{/if}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
{#if authManager.authenticated && authManager.user.isAdmin}
{$t('admin.asset_offline_description')}
{:else}
{$t('asset_offline_description')}
{/if}
</p>
</div>
<div class="rounded-b bg-red-500 px-4 py-2 text-white text-sm">
<p>{asset.originalPath}</p>
</div>
</div>
</section>
{/if}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
{@const ageInMonths = Math.floor(
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
)}
{#if age >= 0}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
{#if ageInMonths <= 11}
{$t('age_months', { values: { months: ageInMonths } })}
{:else if ageInMonths > 12 && ageInMonths <= 23}
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
{:else}
{$t('age_years', { values: { years: age } })}
{/if}
</p>
{/if}
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
{@const ageInMonths = Math.floor(
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
)}
{#if age >= 0}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
{#if ageInMonths <= 11}
{$t('age_months', { values: { months: ageInMonths } })}
{:else if ageInMonths > 12 && ageInMonths <= 23}
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
{:else}
{$t('age_years', { values: { years: age } })}
{/if}
</p>
{/if}
</a>
{/if}
{/each}
</div>
</section>
{/if}
</a>
{/if}
{/each}
</div>
</section>
{/if}
<div class="px-4 py-4">
{#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm">
<Text size="small" color="muted">{$t('details')}</Text>
</div>
{:else}
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<div class="px-4 py-4">
{#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm">
<Text size="small" color="muted">{$t('details')}</Text>
</div>
{:else}
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} />
<DetailPanelDate {asset} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
<div>
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
{asset.originalFileName}
{#if isOwner}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
size="small"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleAssetPath()}
/>
{/if}
</p>
{#if assetViewerManager.isShowAssetPath}
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
{asset.originalPath}
</a>
</p>
{/if}
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
{/if}
</div>
{/if}
</div>
</div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
<div><Icon icon={mdiCamera} size="24" /></div>
<div>
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
{asset.originalFileName}
{#if isOwner}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
size="small"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleAssetPath()}
/>
{/if}
</p>
{#if assetViewerManager.isShowAssetPath}
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
{asset.originalPath}
{#if asset.exifInfo?.make || asset.exifInfo?.model}
<p>
<a
href={Route.search({
make: asset.exifInfo?.make ?? undefined,
model: asset.exifInfo?.model ?? undefined,
})}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
</a>
</p>
{/if}
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
{/if}
</div>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCamera} size="24" /></div>
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
<p>
<a
href={Route.search({
make: asset.exifInfo?.make ?? undefined,
model: asset.exifInfo?.model ?? undefined,
})}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
</a>
</p>
<div>
{#if asset.exifInfo?.lensModel}
<p>
<a
href={Route.search({ lensModel: asset.exifInfo.lensModel })}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.fNumber}
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.lensModel}
<p>
<a
href={Route.search({ lensModel: asset.exifInfo.lensModel })}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo?.fNumber}
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
{/if}
<DetailPanelLocation {isOwner} {asset} />
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>
{#if latlng && featureFlagsManager.value.map}
<div class="h-90">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
mapMarkers={[
{
lat: latlng.lat,
lon: latlng.lng,
id: asset.id,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]}
center={latlng}
showSettings={false}
zoom={12.5}
simplified
useLocationPin
showSimpleControls={!showEditFaces}
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</a>
</div>
{/snippet}
</Map>
{/await}
</div>
{/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<Text size="small" color="muted">{$t('shared_by')}</Text>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" />
</div>
<div class="mb-auto mt-auto">
<p>
{asset.owner.name}
</p>
</div>
</div>
</section>
{/if}
{#if latlng && featureFlagsManager.value.map}
<div class="h-90">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
mapMarkers={[
{
lat: latlng.lat,
lon: latlng.lng,
id: asset.id,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]}
center={latlng}
showSettings={false}
zoom={12.5}
simplified
useLocationPin
showSimpleControls={!assetViewerManager.isEditFacesPanelOpen}
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</a>
</div>
{/snippet}
</Map>
{/await}
</div>
{/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<Text size="small" color="muted">{$t('shared_by')}</Text>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" />
</div>
<div class="mb-auto mt-auto">
<p>
{asset.owner.name}
</p>
</div>
{#await albums then albums}
{#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
</section>
{/if}
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
{#await albums then albums}
{#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
<div class="mb-auto mt-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
<div class="mb-auto mt-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
</div>
</div>
</a>
{/each}
</section>
{/if}
{/await}
{#if authManager.authenticated && authManager.preferences.tags.enabled}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<DetailPanelTags {asset} {isOwner} />
</div>
</a>
{/each}
</section>
{/if}
{/await}
{#if authManager.authenticated && authManager.preferences.tags.enabled}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<DetailPanelTags {asset} {isOwner} />
</section>
{/if}
{#if assetViewerManager.isEditFacesPanelOpen}
{#if showEditFaces}
<PersonSidePanel
assetId={asset.id}
assetType={asset.type}
@@ -47,6 +47,7 @@
style:inset-inline-start={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
@@ -127,27 +127,9 @@
const scrollY = $derived(
toScrollFromTimelineMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
);
const estimateMonthHeight = (assetCount: number) => {
const viewportWidth = timelineManager.viewportWidth;
const rowHeight = timelineManager.rowHeight;
const headerHeight = timelineManager.headerHeight;
if (viewportWidth === 0) {
return headerHeight + rowHeight;
}
const rows = Math.ceil(((3 / 2) * assetCount * rowHeight * (7 / 10)) / viewportWidth);
return headerHeight + Math.max(1, rows) * rowHeight;
};
const totalEstimatedHeight = $derived.by(() => {
let total = timelineManager.topSectionHeight + timelineManager.bottomSectionHeight;
for (const month of timelineManager.scrubberMonths) {
total += estimateMonthHeight(month.assetCount);
}
return total;
});
const relativeTopOffset = $derived(toScrollY(timelineManager.topSectionHeight / totalEstimatedHeight));
const relativeBottomOffset = $derived(toScrollY(timelineManager.bottomSectionHeight / totalEstimatedHeight));
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
const relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
type Segment = {
count: number;
@@ -172,7 +154,7 @@
const reversed = [...months].reverse();
for (const scrubMonth of reversed) {
const scrollBarPercentage = estimateMonthHeight(scrubMonth.assetCount) / totalEstimatedHeight;
const scrollBarPercentage = scrubMonth.height / timelineFullHeight;
const segment = {
top,
@@ -511,13 +493,7 @@
<svelte:window
bind:innerHeight={windowHeight}
onmousemove={(e) => {
if (isDragging && (e.buttons & 1) === 0) {
handleMouseEvent({ clientY: e.clientY, isDragging: false });
} else if (isDragging || isHover) {
handleMouseEvent({ clientY: e.clientY });
}
}}
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/>
+146 -153
View File
@@ -13,17 +13,16 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { clamp } from 'lodash-es';
import { DateTime } from 'luxon';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -102,14 +101,6 @@
let scrubberWidth = $state(0);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const topSectionPlaneTop = $derived(
timelineManager.months.length > 0 ? timelineManager.months[0].planeTop - timelineManager.topSectionHeight : 0,
);
const leadoutPlaneTop = $derived(
timelineManager.months.length > 0
? timelineManager.months.at(-1)!.planeTop + timelineManager.months.at(-1)!.height
: timelineManager.topSectionHeight,
);
const maxMd = $derived(mediaQueryManager.maxMd);
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
@@ -178,15 +169,18 @@
const scrollAndLoadAsset = async (assetId: string) => {
try {
// This flag prevents layout deferral to fix scroll positioning issues.
// When layouts are deferred and we scroll to an asset at the end of the timeline,
// we can calculate the asset's position, but the scrollableElement's scrollHeight
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
const timelineMonth = await timelineManager.findTimelineMonthForAsset({ id: assetId });
if (!timelineMonth) {
return false;
}
const monthIndex = timelineManager.months.indexOf(timelineMonth);
if (monthIndex !== -1) {
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: 0 });
}
scrollToAssetPosition(assetId, timelineMonth);
return true;
} finally {
@@ -219,6 +213,7 @@
scrolled = await scrollAndLoadAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
} else if (scrollTarget) {
await tick();
@@ -268,105 +263,102 @@
}
});
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, timelineMonthScrollPercent: number) => {
const topOffset = segmentTop;
const maxScrollPercent = timelineManager.maxScrollPercent;
const delta = segmentHeight * timelineMonthScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent;
timelineManager.scrollTo(scrollToTop);
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => {
const { scrubberMonth, scrubberMonthScrollPercent } = scrubberData;
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
// For small timelines, use linear percentage for bi-directional sync with handleTimelineScroll
if (timelineManager.limitedScroll) {
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
timelineManager.scrollTo(scrubberData.overallScrollPercent * maxScroll);
return;
}
const leadIn = scrubberMonth === 'lead-in';
const leadOut = scrubberMonth === 'lead-out';
const noMonth = !scrubberMonth;
if (!scrubberMonth) {
if (timelineManager.months.length === 0) {
if (noMonth || timelineManager.limitedScroll) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = timelineManager.maxScrollPercent;
const offset = maxScroll * overallScrollPercent * timelineManager.totalViewerHeight;
timelineManager.scrollTo(offset);
} else if (leadIn) {
scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent);
} else if (leadOut) {
scrollToSegmentPercentage(
timelineManager.topSectionHeight + timelineManager.bodySectionHeight,
timelineManager.bottomSectionHeight,
scrubberMonthScrollPercent,
);
} else {
const timelineMonth = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
);
if (!timelineMonth) {
return;
}
if (scrubberData.overallScrollPercent <= 0) {
const firstMonth = timelineManager.months[0];
timelineManager.scrollTo(firstMonth.planeTop - timelineManager.topSectionHeight);
} else if (scrubberData.overallScrollPercent >= 1) {
const lastMonth = timelineManager.months.at(-1)!;
timelineManager.scrollTo(
lastMonth.planeTop + lastMonth.height + timelineManager.bottomSectionHeight - timelineManager.viewportHeight,
);
}
return;
}
if (scrubberMonth === 'lead-in') {
if (timelineManager.months.length > 0) {
const firstMonth = timelineManager.months[0];
timelineManager.scrollTo(
firstMonth.planeTop - timelineManager.topSectionHeight * (1 - scrubberMonthScrollPercent),
);
}
return;
}
if (scrubberMonth === 'lead-out') {
if (timelineManager.months.length > 0) {
const lastMonth = timelineManager.months.at(-1)!;
timelineManager.scrollTo(
lastMonth.planeTop + lastMonth.height + timelineManager.bottomSectionHeight * scrubberMonthScrollPercent,
);
}
return;
}
const monthIndex = timelineManager.months.findIndex(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
);
if (monthIndex !== -1) {
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: scrubberMonthScrollPercent });
scrollToSegmentPercentage(timelineMonth.top, timelineMonth.height, scrubberMonthScrollPercent);
}
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => {
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
timelineScrollPercent = maxScroll > 0 ? clamp(timelineManager.visibleWindow.top / maxScroll, 0, 1) : 0;
if (!scrollableElement) {
return;
}
// For small timelines, use linear percentage positioning for smooth bi-directional sync
if (timelineManager.limitedScroll) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = timelineManager.maxScroll;
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
return;
}
} else {
timelineScrollPercent = 0;
const intersection = timelineManager.viewportTopMonthIntersection;
if (!intersection?.month) {
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
return;
}
let top = scrollableElement.scrollTop;
let maxScrollPercent = timelineManager.maxScrollPercent;
const firstMonth = timelineManager.months[0];
if (firstMonth && timelineManager.visibleWindow.top < firstMonth.planeTop) {
viewportTopMonth = 'lead-in';
const topSectionTop = firstMonth.planeTop - timelineManager.topSectionHeight;
viewportTopMonthScrollPercent =
timelineManager.topSectionHeight > 0
? Math.max(0, (timelineManager.visibleWindow.top - topSectionTop) / timelineManager.topSectionHeight)
: 0;
return;
}
const monthsLength = timelineManager.months.length;
for (let i = -1; i < monthsLength + 1; i++) {
let timelineMonth: ViewportTopMonth;
let timelineMonthHeight: number;
if (i === -1) {
// lead-in
timelineMonth = 'lead-in';
timelineMonthHeight = timelineManager.topSectionHeight;
} else if (i === monthsLength) {
// lead-out
timelineMonth = 'lead-out';
timelineMonthHeight = timelineManager.bottomSectionHeight;
} else {
timelineMonth = timelineManager.months[i].yearMonth;
timelineMonthHeight = timelineManager.months[i].height;
}
const lastMonth = timelineManager.months.at(-1)!;
const contentBottom = lastMonth.planeTop + lastMonth.height;
if (timelineManager.visibleWindow.top >= contentBottom && timelineManager.bottomSectionHeight > 0) {
viewportTopMonth = 'lead-out';
viewportTopMonthScrollPercent = Math.min(
1,
(timelineManager.visibleWindow.bottom - contentBottom) / timelineManager.bottomSectionHeight,
);
return;
}
let next = top - timelineMonthHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && timelineMonth) {
viewportTopMonth = timelineMonth;
viewportTopMonth = intersection.month.yearMonth;
viewportTopMonthScrollPercent = intersection.viewportTopRatioInMonth;
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
viewportTopMonthScrollPercent = Math.max(0, top / (timelineMonthHeight * maxScrollPercent));
// compensate for lost precision/rounding errors advance to the next bucket, if present
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
viewportTopMonthScrollPercent = 0;
}
break;
}
top = next;
}
}
};
const handleSelectAsset = (asset: TimelineAsset) => {
@@ -602,8 +594,6 @@
{viewportTopMonthScrollPercent}
{viewportTopMonth}
{onScrub}
startScrub={() => timelineManager.setScrubbing(true)}
stopScrub={() => timelineManager.setScrubbing(false)}
bind:scrubberWidth
onScrubKeyDown={(evt) => {
evt.preventDefault();
@@ -632,13 +622,13 @@
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth}
bind:this={scrollableElement}
onscroll={() => (timelineManager.updateSlidingWindow(), handleTimelineScroll(), updateIsScrolling())}
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible
style:height={timelineManager.planeHeight + 'px'}
style:height={timelineManager.totalViewerHeight + 'px'}
>
<section
bind:clientHeight={timelineManager.topSectionHeight}
@@ -646,7 +636,6 @@
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${topSectionPlaneTop}px,0)`}
>
{@render children?.()}
{#if isEmpty}
@@ -657,66 +646,70 @@
{#each timelineManager.months as timelineMonth (timelineMonth.viewId)}
{@const isInOrNearViewport = timelineMonth.isInOrNearViewport}
{@const absoluteHeight = timelineMonth.planeTop}
{@const absoluteHeight = timelineMonth.top}
{#if isInOrNearViewport}
{#if !timelineMonth.isLoaded}
<div
style:height={timelineMonth.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
{#if timelineMonth.isLoaded}
<div class="timeline-month" style:height={timelineMonth.height + 'px'} style:width="100%">
<Month
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{timelineMonth}
manager={timelineManager}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
return;
}
void onSelectAssets(asset);
}}
onMouseEvent={() => handleSelectAssetCandidates(asset)}
onPreview={isSelectionMode || assetInteraction.selectionActive
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
: undefined}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
</div>
{:else}
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
{/if}
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
</div>
{:else if isInOrNearViewport}
<div
class="timeline-month"
style:height={timelineMonth.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Month
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{timelineMonth}
manager={timelineManager}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
return;
}
void onSelectAssets(asset);
}}
onMouseEvent={() => handleSelectAssetCandidates(asset)}
onPreview={isSelectionMode || assetInteraction.selectionActive
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
: undefined}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
</div>
{/if}
{/each}
@@ -726,7 +719,7 @@
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${leadoutPlaneTop}px,0)`}
style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.bodySectionHeight}px,0)`}
></div>
</section>
</section>
@@ -6,30 +6,11 @@ type LayoutOptions = {
gap: number;
};
export abstract class VirtualScrollManager {
static readonly PLANE_SIZE = 500_000;
static readonly PLANE_CENTER = 250_000;
planeHeight = $state(VirtualScrollManager.PLANE_SIZE);
#topSectionHeight = $state(0);
topSectionHeight = $state(0);
bodySectionHeight = $state(0);
bottomSectionHeight = $state(0);
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
get topSectionHeight() {
return this.#topSectionHeight;
}
set topSectionHeight(value: number) {
if (this.#topSectionHeight === value) {
return;
}
const oldValue = this.#topSectionHeight;
this.#topSectionHeight = value;
this.onTopSectionHeightChanged(oldValue, value);
}
protected onTopSectionHeightChanged(_oldHeight: number, _newHeight: number) {}
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
@@ -209,6 +209,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
this.closeFaceEditMode();
this.closeEditFacesPanel();
}
setAsset(asset: AssetResponseDto) {
this.#viewingAssetStoreState = asset;
this.#viewState = true;
@@ -42,8 +42,8 @@ function calculateViewportProximity(regionTop: number, regionBottom: number, win
export function updateTimelineMonthViewportProximity(timelineManager: TimelineManager, month: TimelineMonth) {
const proximity = calculateViewportProximity(
month.planeTop,
month.planeTop + month.height,
month.top,
month.top + month.height,
timelineManager.visibleWindow.top,
timelineManager.visibleWindow.bottom,
);
@@ -152,6 +152,6 @@ export class TimelineDay {
}
get absoluteTimelineDayTop() {
return this.timelineMonth.planeTop + this.#top;
return this.timelineMonth.top + this.#top;
}
}
@@ -70,17 +70,9 @@ export class TimelineManager extends VirtualScrollManager {
months: TimelineMonth[] = $state([]);
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0);
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
anchorMonthIndex: number = -1;
anchorPlaneTop: number = VirtualScrollManager.PLANE_CENTER;
#recenterTimer: ReturnType<typeof setTimeout> | undefined;
#recentering = false;
#scrubbing = false;
limitedScroll = $derived(
this.months.length > 0 &&
this.totalViewerHeight <= VirtualScrollManager.PLANE_SIZE &&
this.viewportHeight > this.months.at(-1)!.height + this.bottomSectionHeight,
);
limitedScroll = $derived(this.maxScrollPercent < 0.5);
initTask = new CancellableTask(
() => {
this.isInitialized = true;
@@ -130,22 +122,6 @@ export class TimelineManager extends VirtualScrollManager {
return this.#scrollableElement?.scrollTop ?? 0;
}
protected override onTopSectionHeightChanged(oldHeight: number, newHeight: number) {
if (this.anchorMonthIndex === -1 || this.months.length === 0) {
return;
}
const delta = newHeight - oldHeight;
const scrollTopBefore = this.scrollTop;
this.anchorPlaneTop += delta;
this.positionMonthsOnPlane();
// If the user is still inside the lead-in, no month content is visible to keep
// pinned, and shifting scrollTop would push them past the lead-in.
if (scrollTopBefore <= oldHeight) {
return;
}
this.scrollBy(delta);
}
set scrollableElement(element: HTMLElement | undefined) {
this.#scrollableElement = element;
}
@@ -213,7 +189,7 @@ export class TimelineManager extends VirtualScrollManager {
return 0;
}
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
const bottomOfMonth = month.planeTop + month.height;
const bottomOfMonth = month.top + month.height;
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
}
@@ -222,7 +198,7 @@ export class TimelineManager extends VirtualScrollManager {
if (!month) {
return 0;
}
return clamp((this.visibleWindow.top - month.planeTop) / month.height, 0, 1);
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
}
override updateViewportProximities() {
@@ -262,177 +238,6 @@ export class TimelineManager extends VirtualScrollManager {
}
}
/**
* Derives every month's planeTop by walking outward from the anchor. The anchor
* stays pinned at anchorPlaneTop, so any height change elsewhere shifts months
* away from the anchor content at the viewport-top stays stable as long as
* trackAnchorToViewportTop ran beforehand.
*/
positionMonthsOnPlane() {
if (this.months.length === 0 || this.anchorMonthIndex === -1) {
return;
}
const anchor = this.months[this.anchorMonthIndex];
anchor.planeTop = this.anchorPlaneTop;
let cursorBelow = this.anchorPlaneTop + anchor.height;
for (let i = this.anchorMonthIndex + 1; i < this.months.length; i++) {
const month = this.months[i];
month.planeTop = cursorBelow;
cursorBelow += month.height;
}
let cursorAbove = this.anchorPlaneTop;
for (let i = this.anchorMonthIndex - 1; i >= 0; i--) {
const month = this.months[i];
cursorAbove -= month.height;
month.planeTop = cursorAbove;
}
const lastMonth = this.months.at(-1)!;
const contentBottom = lastMonth.planeTop + lastMonth.height + this.bottomSectionHeight;
this.planeHeight = Math.min(VirtualScrollManager.PLANE_SIZE, contentBottom);
}
/** Soft repoint: change the anchor month without moving any planeTop or scrollTop. */
trackAnchorToViewportTop() {
if (this.months.length === 0) {
return;
}
const visibleTop = this.visibleWindow.top;
let newAnchorIndex = -1;
for (let i = 0; i < this.months.length; i++) {
const month = this.months[i];
if (month.planeTop + month.height > visibleTop) {
newAnchorIndex = i;
break;
}
}
if (newAnchorIndex === -1 || newAnchorIndex === this.anchorMonthIndex) {
return;
}
this.anchorMonthIndex = newAnchorIndex;
this.anchorPlaneTop = this.months[newAnchorIndex].planeTop;
}
// Each scroll event resets this timer, so a brief pause in scrolling recenters
// the plane. Continuous scrolling near a plane edge bypasses it via isNearPlaneEdge.
static readonly RECENTER_DEBOUNCE_MS = 50;
static readonly PLANE_EDGE_THRESHOLD = 50_000;
#scheduleRecenter() {
clearTimeout(this.#recenterTimer);
this.#recenterTimer = setTimeout(() => {
this.#recenterTimer = undefined;
this.recenterPlane();
}, TimelineManager.RECENTER_DEBOUNCE_MS);
}
isNearPlaneEdge(): boolean {
return (
this.scrollTop < TimelineManager.PLANE_EDGE_THRESHOLD ||
this.scrollTop > VirtualScrollManager.PLANE_SIZE - TimelineManager.PLANE_EDGE_THRESHOLD
);
}
/**
* Hard repoint: slide every planeTop and scrollTop to pull the anchor back
* toward PLANE_CENTER, or pin month 0 to topSectionHeight when it fits.
*/
recenterPlane() {
clearTimeout(this.#recenterTimer);
this.#recenterTimer = undefined;
if (this.#recentering || this.months.length === 0 || this.anchorMonthIndex === -1) {
return;
}
const viewportTopMonth = this.months[this.anchorMonthIndex];
if (!viewportTopMonth) {
return;
}
// Pin months[0] when the visible content still fits on the plane alongside it.
// Only the downward distance is checked because nothing exists above month 0.
// Fall back to PLANE_CENTER recycling only when month 0 no longer fits.
const firstMonth = this.months[0];
const viewportTopOffsetFromFirstMonth = viewportTopMonth.planeTop - firstMonth.planeTop + this.topSectionHeight;
const canPinFirstMonth =
viewportTopOffsetFromFirstMonth + this.viewportHeight <=
VirtualScrollManager.PLANE_SIZE - TimelineManager.PLANE_EDGE_THRESHOLD;
let targetMonth: TimelineMonth;
let targetPlaneTop: number;
if (canPinFirstMonth || this.anchorMonthIndex === 0) {
targetMonth = firstMonth;
targetPlaneTop = this.topSectionHeight;
} else {
targetMonth = viewportTopMonth;
targetPlaneTop = VirtualScrollManager.PLANE_CENTER;
}
const monthIndex = this.months.indexOf(targetMonth);
const delta = targetPlaneTop - targetMonth.planeTop;
if (delta === 0) {
return;
}
// Same lead-in guard as onTopSectionHeightChanged.
const preserveScrollTop = this.scrollTop <= this.topSectionHeight;
this.#recentering = true;
try {
for (const month of this.months) {
month.planeTop += delta;
}
this.anchorMonthIndex = monthIndex;
this.anchorPlaneTop = targetPlaneTop;
if (this.#scrollableElement && !preserveScrollTop) {
this.#scrollableElement.scrollTop += delta;
}
const lastMonth = this.months.at(-1)!;
const contentBottom = lastMonth.planeTop + lastMonth.height + this.bottomSectionHeight;
this.planeHeight = Math.min(VirtualScrollManager.PLANE_SIZE, contentBottom);
this.updateSlidingWindow();
} finally {
this.#recentering = false;
}
}
override updateSlidingWindow() {
super.updateSlidingWindow();
if (this.#recentering || this.#scrubbing) {
return;
}
this.trackAnchorToViewportTop();
// Continuous scroll keeps resetting the debounce timer, so if scrollTop is
// already near a plane edge we have to recenter immediately or risk hitting it.
if (this.isNearPlaneEdge()) {
this.recenterPlane();
return;
}
this.#scheduleRecenter();
}
setScrubbing(value: boolean) {
this.#scrubbing = value;
if (!value) {
this.updateSlidingWindow();
}
}
jumpToMonth({ monthIndex, fractionInMonth }: { monthIndex: number; fractionInMonth: number }) {
clearTimeout(this.#recenterTimer);
this.#recenterTimer = undefined;
if (this.months.length === 0) {
return;
}
const month = this.months[monthIndex];
if (!month) {
return;
}
this.anchorMonthIndex = monthIndex;
this.anchorPlaneTop = monthIndex === 0 ? this.topSectionHeight : VirtualScrollManager.PLANE_CENTER;
this.positionMonthsOnPlane();
this.scrollTo(month.planeTop + fractionInMonth * month.height);
}
async #initializeTimelineMonths() {
const timebuckets = await getTimeBuckets({
...authManager.params,
@@ -475,7 +280,6 @@ export class TimelineManager extends VirtualScrollManager {
async #init(options: TimelineManagerOptions) {
this.isInitialized = false;
this.months = [];
this.anchorMonthIndex = -1;
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
@@ -520,13 +324,6 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: changedWidth });
}
if (this.months.length > 0 && this.anchorMonthIndex === -1) {
this.anchorMonthIndex = 0;
this.anchorPlaneTop = this.topSectionHeight;
}
this.positionMonthsOnPlane();
this.updateViewportProximities();
if (changedWidth) {
this.#createScrubberMonths();
@@ -539,7 +336,9 @@ export class TimelineManager extends VirtualScrollManager {
year: month.yearMonth.year,
month: month.yearMonth.month,
title: month.title,
height: month.height,
}));
this.scrubberTimelineHeight = this.totalViewerHeight;
}
async loadTimelineMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
@@ -36,7 +36,7 @@ export class TimelineMonth {
readonly timelineManager: TimelineManager;
#height: number = $state(0);
#planeTop: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
@@ -266,36 +266,39 @@ export class TimelineMonth {
return;
}
const timelineManager = this.timelineManager;
// Repin the anchor BEFORE positionMonthsOnPlane re-derives planeTops, so the
// recomputation only shifts content above the viewport (invisible to the user).
timelineManager.trackAnchorToViewportTop();
// When this month is the viewport-top one, its photos will reflow as the height
// settles from estimate to actual; capture the user's fractional position so we
// can restore it below and avoid the visible stutter.
const isViewportTopMonth =
timelineManager.anchorMonthIndex !== -1 &&
timelineManager.months[timelineManager.anchorMonthIndex] === this &&
this.#height > 0;
const scrollFractionInMonth = isViewportTopMonth ? (timelineManager.scrollTop - this.#planeTop) / this.#height : 0;
const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const previousTimelineMonth = timelineManager.months[index - 1];
if (previousTimelineMonth) {
const newTop = previousTimelineMonth.#top + previousTimelineMonth.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
if (heightDelta === 0) {
return;
}
// Shift the anchor instead of scrollTop — touching scrollTop here fights
// native scroll momentum on Safari and visibly stutters.
if (isViewportTopMonth && scrollFractionInMonth > 0) {
timelineManager.anchorPlaneTop -= heightDelta * scrollFractionInMonth;
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const timelineMonth = this.timelineManager.months[cursor];
const newTop = timelineMonth.#top + heightDelta;
if (timelineMonth.#top !== newTop) {
timelineMonth.#top = newTop;
}
}
timelineManager.positionMonthsOnPlane();
// Async loads change heights without going through updateSlidingWindow, so the
// near-edge check needs to run here too.
if (timelineManager.isNearPlaneEdge()) {
timelineManager.recenterPlane();
if (!timelineManager.viewportTopMonthIntersection) {
return;
}
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
if (!month || currentIndex <= 0 || index > currentIndex) {
return;
}
if (index < currentIndex || monthBottomViewportRatio < 1) {
timelineManager.scrollBy(heightDelta);
} else if (index === currentIndex) {
const scrollTo = this.top + height * viewportTopRatioInMonth;
timelineManager.scrollTo(scrollTo);
}
}
@@ -303,12 +306,8 @@ export class TimelineMonth {
return this.#height;
}
get planeTop(): number {
return this.#planeTop;
}
set planeTop(value: number) {
this.#planeTop = value;
get top(): number {
return this.#top + this.timelineManager.topSectionHeight;
}
#handleLoadError(error: unknown) {
@@ -338,7 +337,7 @@ export class TimelineMonth {
return;
}
return {
top: this.planeTop + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
height: viewerAsset.position.height,
};
}
@@ -79,6 +79,7 @@ export interface UpdateStackAssets {
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
export type ScrubberMonth = {
height: number;
assetCount: number;
year: number;
month: number;
+1
View File
@@ -51,6 +51,7 @@ export const Docs = {
export const Route = {
// auth
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
authLink: (params?: { email?: string }) => '/auth/link' + asQueryString(params),
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
register: () => '/auth/register',
changePassword: () => '/auth/change-password',
-4
View File
@@ -15,7 +15,6 @@ import {
getBaseUrl,
getPeopleThumbnailPath,
getUserProfileImagePath,
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type AssetResponseDto,
@@ -293,9 +292,6 @@ export const oauth = {
login: (location: Location) => {
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location) => {
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
},
unlink: () => {
return unlinkOAuthAccount();
},
@@ -2,32 +2,13 @@
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { Button, Stack, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let loading = $state(true);
onMount(async () => {
if (oauth.isCallback(globalThis.location)) {
try {
loading = true;
const response = await oauth.link(globalThis.location);
authManager.setUser(response);
toastManager.primary($t('linked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
await goto('?open=oauth');
}
}
loading = false;
});
const handleUnlink = async () => {
try {
const response = await oauth.unlink();
@@ -39,22 +20,28 @@
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end">
{#if loading}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{:else if featureFlagsManager.value.oauth}
{#if featureFlagsManager.value.oauth}
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<Stack gap={3}>
{#if authManager.user.oauthId}
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
<Text>{$t('oauth_account_is_linked')}</Text>
{#if featureFlagsManager.value.passwordLogin}
<div class="sm:ms-8 flex justify-end">
<Button shape="round" size="small" color="danger" onclick={() => handleUnlink()}>
{$t('unlink_oauth')}
</Button>
</div>
{/if}
{:else}
<Button shape="round" size="small" onclick={() => oauth.authorize(globalThis.location)}
>{$t('link_to_oauth')}</Button
>
<Text>{$t('oauth_account_not_linked')}</Text>
<div class="sm:ms-8 flex justify-end">
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}>
{$t('link_to_oauth')}
</Button>
</div>
{/if}
{/if}
</Stack>
</div>
</div>
</section>
</section>
{/if}
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { isHttpError, login, register, startOAuthReLink } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
let email = $state(data.email || authManager.user?.email || '');
let password = $state('');
let errorMessage = $state('');
let loading = $state(false);
let registering = $state(false);
let reLinkMode = $state(!!data.reLinkToken);
let reLinkLoading = $state(!!data.reLinkToken);
let reLinkError = $state('');
onMount(async () => {
if (oauth.isCallback(globalThis.location)) {
reLinkLoading = true;
try {
const user = await oauth.login(globalThis.location);
eventManager.emit('AuthLogin', user);
await authManager.refresh();
toastManager.primary($t('linked_oauth_account'));
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
reLinkLoading = false;
reLinkMode = false;
reLinkError =
getServerErrorMessage(error) ||
(isHttpError(error) ? error.message : undefined) ||
$t('errors.unable_to_complete_oauth_login');
}
return;
}
if (data.reLinkToken) {
try {
await startOAuthReLink({ oAuthReLinkStartDto: { token: data.reLinkToken } });
await oauth.authorize(globalThis.location);
} catch (error) {
reLinkLoading = false;
reLinkMode = false;
reLinkError = getServerErrorMessage(error) || $t('errors.invalid_oauth_relink_token');
}
}
});
const handleSubmit = async (event: Event) => {
event.preventDefault();
try {
errorMessage = '';
loading = true;
const user = await login({ loginCredentialDto: { email, password } });
eventManager.emit('AuthLogin', user);
await authManager.refresh();
toastManager.primary($t('linked_oauth_account'));
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
loading = false;
}
};
const handleRegister = async () => {
try {
registering = true;
const user = await register();
eventManager.emit('AuthLogin', user);
await authManager.refresh();
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
registering = false;
}
};
</script>
<AuthPageLayout title={data.meta.title}>
<Stack gap={4}>
{#if reLinkError}
<Alert color="danger" title={reLinkError} closable />
{/if}
{#if reLinkMode && reLinkLoading}
<Alert color="primary">
{$t('oauth_relink_in_progress')}
</Alert>
{:else}
{#if featureFlagsManager.value.passwordLogin}
<Alert color="primary">
{$t('oauth_link_existing_account')}
</Alert>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
{$t('to_login')}
</Button>
</form>
{:else}
<Alert color="warning">
{$t('oauth_link_password_login_required')}
</Alert>
{/if}
{#if featureFlagsManager.value.oauthAutoRegister}
{#if featureFlagsManager.value.passwordLogin}
<div class="inline-flex w-full items-center justify-center my-4">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
>
{$t('or')}
</span>
</div>
{/if}
<Button
shape="round"
size="large"
fullWidth
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
loading={registering}
onclick={handleRegister}
>
{$t('create_new_account')}
</Button>
{/if}
{/if}
</Stack>
</AuthPageLayout>
+16
View File
@@ -0,0 +1,16 @@
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
const email = url.searchParams.get('email') || '';
const reLinkToken = url.searchParams.get('token') || '';
const $t = await getFormatter();
return {
meta: {
title: $t('link_to_oauth'),
},
email,
reLinkToken,
};
}) satisfies PageLoad;
+23 -2
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { OpenQueryParam } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { login, type LoginResponseDto } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
import { isHttpError, login, type LoginResponseDto } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -43,6 +45,20 @@
}
if (oauth.isCallback(globalThis.location)) {
const params = new URLSearchParams(globalThis.location.search);
if (params.has('error')) {
if (authManager.authenticated) {
const message = params.get('error_description') || $t('errors.unable_to_link_oauth_account');
await goto(Route.userSettings({ isOpen: OpenQueryParam.OAUTH }));
toastManager.warning(message);
} else {
oauthError =
params.get('error_description') || params.get('error') || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
}
return;
}
try {
const user = await oauth.login(globalThis.location);
@@ -54,6 +70,11 @@
await onSuccess(user);
return;
} catch (error) {
if (isHttpError(error) && error.data?.message === 'oauth_account_link_required') {
const errorData = error.data as unknown as Record<string, string>;
await goto(Route.authLink({ email: errorData.userEmail }));
return;
}
console.error('Error [login-form] [oauth.callback]', error);
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
+3 -1
View File
@@ -9,7 +9,9 @@ export const load = (async ({ parent, url }) => {
await parent();
const continueUrl = url.searchParams.get('continue') || Route.photos();
if (authManager.authenticated) {
const isOAuthCallback = url.searchParams.has('code') || url.searchParams.has('error');
const isOAuthAutoLaunch = url.searchParams.has('autoLaunch');
if (authManager.authenticated && !isOAuthCallback && !isOAuthAutoLaunch) {
redirect(307, continueUrl);
}