mirror of
https://github.com/immich-app/immich.git
synced 2026-05-16 04:22:17 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f83e7c66 | |||
| 2da2bef777 | |||
| fd52481582 | |||
| e583e3c55a | |||
| 12e36ad082 | |||
| f4e016edb5 | |||
| d50ea005a1 | |||
| b8c373f0f1 | |||
| b3e5ec48e6 | |||
| 058bd40708 | |||
| 81a885c31d | |||
| 9b7f75a407 | |||
| b42fdcfca9 | |||
| 5731c261eb |
@@ -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 |
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
+4
-30
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Generated
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+40
-122
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user