mirror of
https://github.com/immich-app/immich.git
synced 2026-03-21 09:38:44 -07:00
Compare commits
19 Commits
fix/map-un
...
renovate/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f8a1e243 | ||
|
|
00dae6ac38 | ||
|
|
5a8fd40dc5 | ||
|
|
813d684aaa | ||
|
|
644f705be1 | ||
|
|
f3e4bcc733 | ||
|
|
9a0c17fdb8 | ||
|
|
b7c4497dfd | ||
|
|
9c227aeaf5 | ||
|
|
e939fde6f1 | ||
|
|
019beaed0b | ||
|
|
0e4d6d4eac | ||
|
|
79f978ddeb | ||
|
|
f2445ecab1 | ||
|
|
86311e3cfe | ||
|
|
29000461c2 | ||
|
|
b30373b24f | ||
|
|
bc2439883a | ||
|
|
044257531e |
25
.github/workflows/check-pr-template.yml
vendored
25
.github/workflows/check-pr-template.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
|
||||
|
||||
jobs:
|
||||
parse:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,9 +49,10 @@ jobs:
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
@@ -60,21 +64,34 @@ jobs:
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
addLabelsToLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Reopen PR (sections now present, PR closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
||||
- name: Reopen PR (sections now present, PR was auto-closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
mutation ReopenPR($prId: ID!, $labelId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
removeLabelsFromLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
1
.github/workflows/prepare-release.yml
vendored
1
.github/workflows/prepare-release.yml
vendored
@@ -151,6 +151,7 @@ jobs:
|
||||
body_path: misc/release/notes.tmpl
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.0",
|
||||
"url": "https://docs.v2.6.0.archive.immich.app"
|
||||
"label": "v2.6.1",
|
||||
"url": "https://docs.v2.6.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -16,7 +16,7 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.30.3"
|
||||
pnpm = "10.32.1"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.5"
|
||||
java = "21.0.2"
|
||||
|
||||
@@ -23,10 +23,18 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import org.chromium.net.CronetEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.SimpleFileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.net.Authenticator
|
||||
import java.net.CookieHandler
|
||||
import java.net.PasswordAuthentication
|
||||
@@ -277,10 +285,13 @@ object HttpClientManager {
|
||||
return result
|
||||
}
|
||||
|
||||
fun rebuildCronetEngine(): CronetEngine {
|
||||
val old = cronetEngine!!
|
||||
cronetEngine = buildCronetEngine()
|
||||
return old
|
||||
suspend fun rebuildCronetEngine(): Result<Long> {
|
||||
return runCatching {
|
||||
cronetEngine?.shutdown()
|
||||
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
|
||||
cronetEngine = buildCronetEngine()
|
||||
deletionResult
|
||||
}
|
||||
}
|
||||
|
||||
val cronetStoragePath: File get() = cronetStorageDir
|
||||
@@ -301,7 +312,7 @@ object HttpClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCronetEngine(): CronetEngine {
|
||||
fun buildCronetEngine(): CronetEngine {
|
||||
return CronetEngine.Builder(appContext)
|
||||
.enableHttp2(true)
|
||||
.enableQuic(true)
|
||||
@@ -312,6 +323,27 @@ object HttpClientManager {
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
|
||||
var totalSize = 0L
|
||||
|
||||
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
|
||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
totalSize += attrs.size()
|
||||
Files.delete(file)
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
|
||||
if (dir != root) {
|
||||
Files.delete(dir)
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
})
|
||||
|
||||
totalSize
|
||||
}
|
||||
|
||||
private fun build(cacheDir: File): OkHttpClient {
|
||||
val connectionPool = ConnectionPool(
|
||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||
|
||||
@@ -21,11 +21,6 @@ import java.io.EOFException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.SimpleFileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
@@ -205,18 +200,15 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
|
||||
private fun onDrained() {
|
||||
val onCacheCleared = synchronized(stateLock) {
|
||||
val onCacheCleared = onCacheCleared
|
||||
val onCacheCleared = this.onCacheCleared
|
||||
this.onCacheCleared = null
|
||||
onCacheCleared
|
||||
}
|
||||
if (onCacheCleared != null) {
|
||||
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
||||
oldEngine.shutdown()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
||||
synchronized(stateLock) { draining = false }
|
||||
onCacheCleared(result)
|
||||
}
|
||||
} ?: return
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = HttpClientManager.rebuildCronetEngine()
|
||||
synchronized(stateLock) { draining = false }
|
||||
onCacheCleared(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,26 +298,6 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
|
||||
var totalSize = 0L
|
||||
|
||||
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
|
||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
totalSize += attrs.size()
|
||||
Files.delete(file)
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
|
||||
if (dir != root) {
|
||||
Files.delete(dir)
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
})
|
||||
|
||||
totalSize
|
||||
}
|
||||
}
|
||||
|
||||
private class OkHttpImageFetcher private constructor(
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3038,
|
||||
"android.injected.version.name" => "2.6.0",
|
||||
"android.injected.version.code" => 3039,
|
||||
"android.injected.version.name" => "2.6.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.0</string>
|
||||
<string>2.6.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
|
||||
'Arabic (ar)': Locale('ar'),
|
||||
'Bulgarian (bg)': Locale('bg'),
|
||||
'Catalan (ca)': Locale('ca'),
|
||||
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
|
||||
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
|
||||
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
|
||||
'Croatian (hr)': Locale('hr'),
|
||||
'Czech (cs)': Locale('cs'),
|
||||
|
||||
@@ -28,17 +28,6 @@ class AppNavigationObserver extends AutoRouterObserver {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
_handleLockedViewState(previousRoute ?? route, null);
|
||||
_handleDriftLockedFolderState(previousRoute ?? route, null);
|
||||
Future(() {
|
||||
ref.read(currentRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
||||
ref.read(previousRouteNameProvider.notifier).state = ref.read(previousRouteNameProvider);
|
||||
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
||||
});
|
||||
}
|
||||
|
||||
_handleLockedViewState(Route route, Route? previousRoute) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final isFromLockedViewToDetailView =
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -184,8 +185,8 @@ class ApiService {
|
||||
if (externalJson != null) {
|
||||
final List<dynamic> list = jsonDecode(externalJson);
|
||||
for (final entry in list) {
|
||||
final url = entry['url'] as String?;
|
||||
if (url != null && url.isNotEmpty) urls.add(url);
|
||||
final url = AuxilaryEndpoint.fromJson(entry).url;
|
||||
if (url.isNotEmpty) urls.add(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
|
||||
@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
|
||||
set maximumSize(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
set maximumSizeBytes(int value) => _small.maximumSize = value;
|
||||
set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.6.0
|
||||
- API version: 2.6.1
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.0+3038
|
||||
version: 2.6.1+3039
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -15166,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.0
|
||||
* 2.6.1
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
214
plugins/package-lock.json
generated
214
plugins/package-lock.json
generated
@@ -15,9 +15,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -32,9 +32,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -49,9 +49,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -66,9 +66,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -83,9 +83,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -100,9 +100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -117,9 +117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -134,9 +134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -151,9 +151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -168,9 +168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -185,9 +185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -202,9 +202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -219,9 +219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -236,9 +236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -253,9 +253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -270,9 +270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -287,9 +287,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -304,9 +304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -321,9 +321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -338,9 +338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -355,9 +355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -372,9 +372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -389,9 +389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -406,9 +406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -423,9 +423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -440,9 +440,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -467,9 +467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -480,32 +480,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
1519
pnpm-lock.yaml
generated
1519
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -24,7 +24,7 @@
|
||||
"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 generate",
|
||||
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
|
||||
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
|
||||
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
|
||||
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
|
||||
|
||||
@@ -169,6 +169,7 @@ export type AuthSharedLink = {
|
||||
id: string;
|
||||
expiresAt: Date | null;
|
||||
userId: string;
|
||||
albumId: string | null;
|
||||
showExif: boolean;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
@@ -357,15 +358,6 @@ export const columns = {
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
authSharedLink: [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
],
|
||||
user: userColumns,
|
||||
userWithPrefix: userWithPrefixColumns,
|
||||
userAdmin: [
|
||||
|
||||
@@ -173,6 +173,7 @@ order by
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."albumId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
@@ -211,6 +212,7 @@ where
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."albumId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
|
||||
@@ -330,6 +330,7 @@ export class AlbumRepository {
|
||||
await db
|
||||
.insertInto('album_asset')
|
||||
.values(assetIds.map((assetId) => ({ albumId, assetId })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,12 @@ export class MetadataRepository {
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
// If exiftool assigns a field with ^= instead of =, empty values will be written too.
|
||||
// Since exiftool-vendored doesn't support an option for this, we append the ^ to the name of the tag instead.
|
||||
// https://exiftool.org/exiftool_pod.html#:~:text=is%20used%20to%20write%20an%20empty%20string
|
||||
const tagsToWrite = Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key}^`, value]));
|
||||
try {
|
||||
await this.exiftool.write(path, tags);
|
||||
await this.exiftool.write(path, tagsToWrite);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
|
||||
@@ -202,7 +202,14 @@ export class SharedLinkRepository {
|
||||
.leftJoin('album', 'album.id', 'shared_link.albumId')
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select((eb) => [
|
||||
...columns.authSharedLink,
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.albumId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
jsonObjectFrom(
|
||||
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
|
||||
).as('user'),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
DELETE FROM "shared_link_asset"
|
||||
USING "shared_link"
|
||||
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// noop
|
||||
}
|
||||
@@ -64,8 +64,9 @@ export class UserTable {
|
||||
@Column({ unique: true, nullable: true, default: null })
|
||||
storageLabel!: string | null;
|
||||
|
||||
// TODO remove default, make nullable, and convert empty spaces to null
|
||||
@Column({ default: '' })
|
||||
name!: Generated<string>;
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
quotaSizeInBytes!: ColumnType<number> | null;
|
||||
|
||||
@@ -165,6 +165,12 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
|
||||
);
|
||||
}
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
|
||||
|
||||
@@ -195,6 +201,12 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
|
||||
);
|
||||
}
|
||||
|
||||
const results: AlbumsAddAssetsResponseDto = {
|
||||
success: false,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset } from 'src/database';
|
||||
import { Asset, AuthSharedLink } from 'src/database';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
@@ -152,7 +152,7 @@ export class AssetMediaService extends BaseService {
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
|
||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
@@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
|
||||
await (sharedLink.albumId
|
||||
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
|
||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
@@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthService } 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';
|
||||
import { OAuthProfileFactory } from 'test/factories/oauth-profile.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
@@ -15,31 +16,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const oauthResponse = ({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
profileImagePath,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
profileImagePath?: string;
|
||||
}) => ({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: id,
|
||||
userEmail: email,
|
||||
name,
|
||||
profileImagePath,
|
||||
isAdmin: false,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
@@ -48,11 +25,9 @@ const loginDetails = {
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
email,
|
||||
password: 'password',
|
||||
},
|
||||
const dto = {
|
||||
email,
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
describe(AuthService.name, () => {
|
||||
@@ -63,7 +38,6 @@ describe(AuthService.name, () => {
|
||||
({ sut, mocks } = newTestService(AuthService));
|
||||
|
||||
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
|
||||
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||
});
|
||||
|
||||
@@ -75,13 +49,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(fixtures.login, 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(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -89,7 +63,7 @@ describe(AuthService.name, () => {
|
||||
it('should check the user has a password', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -100,7 +74,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(session);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
|
||||
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
@@ -624,6 +598,7 @@ describe(AuthService.name, () => {
|
||||
it('should not allow auto registering', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -638,31 +613,31 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
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: sub });
|
||||
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({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -677,35 +652,30 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const user = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -725,10 +695,9 @@ describe(AuthService.name, () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
@@ -743,135 +712,136 @@ describe(AuthService.name, () => {
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
it('should infer name from given and family names', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
||||
);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
|
||||
});
|
||||
|
||||
it('should fallback to email when no username is provided', async () => {
|
||||
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.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());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(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());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
isAdmin: false,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 0,
|
||||
storageLabel: null,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||
mocks.oauth.getProfile.mockResolvedValue(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(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
isAdmin: false,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 5_368_709_120,
|
||||
storageLabel: null,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
|
||||
});
|
||||
|
||||
it('should sync the profile picture', async () => {
|
||||
const fileId = newUuid();
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: pictureUrl,
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||
@@ -881,131 +851,96 @@ describe(AuthService.name, () => {
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
|
||||
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
|
||||
profileChangedAt: expect.any(Date),
|
||||
});
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
||||
});
|
||||
|
||||
it('should not sync the profile picture if the user already has one', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
OAuthProfileFactory.create({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||
}),
|
||||
);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
||||
mocks.oauth.getProfile.mockResolvedValue(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(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
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 () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: true,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||
});
|
||||
|
||||
it('should accept a custom role claim', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' });
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: true,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1013,8 +948,10 @@ describe(AuthService.name, () => {
|
||||
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.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.link(
|
||||
@@ -1023,7 +960,7 @@ describe(AuthService.name, () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
@@ -1032,6 +969,7 @@ describe(AuthService.name, () => {
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -261,6 +261,11 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const expectedState = dto.state ?? this.getCookieOauthState(headers);
|
||||
if (!expectedState?.length) {
|
||||
throw new BadRequestException('OAuth state is missing');
|
||||
@@ -271,7 +276,6 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('OAuth code verifier is missing');
|
||||
}
|
||||
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||
@@ -298,7 +302,8 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
if (!profile.email) {
|
||||
const email = profile.email;
|
||||
if (!email) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
|
||||
@@ -320,10 +325,13 @@ export class AuthService extends BaseService {
|
||||
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
|
||||
});
|
||||
|
||||
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
|
||||
user = await this.createUser({
|
||||
name: userName,
|
||||
email: profile.email,
|
||||
name:
|
||||
profile.name ||
|
||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||
profile.preferred_username ||
|
||||
email,
|
||||
email,
|
||||
oauthId: profile.sub,
|
||||
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
|
||||
storageLabel: storageLabel || null,
|
||||
|
||||
@@ -467,7 +467,7 @@ export class MetadataService extends BaseService {
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
TagsList: tags?.length ? tags : undefined,
|
||||
TagsList: tags,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
@@ -130,6 +131,32 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onConfigUpdate', () => {
|
||||
it('should queue a version check job when newVersionCheck is enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck is disabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onWebsocketConnection', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
|
||||
@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
|
||||
return this.versionRepository.getAll();
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate' })
|
||||
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
|
||||
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
|
||||
await this.handleQueueVersionCheck();
|
||||
}
|
||||
}
|
||||
|
||||
async handleQueueVersionCheck() {
|
||||
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
|
||||
}
|
||||
|
||||
28
server/test/factories/oauth-profile.factory.ts
Normal file
28
server/test/factories/oauth-profile.factory.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { OAuthProfileLike } from 'test/factories/types';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export class OAuthProfileFactory {
|
||||
private constructor(private value: OAuthProfile) {}
|
||||
|
||||
static create(dto: OAuthProfileLike = {}) {
|
||||
return OAuthProfileFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: OAuthProfileLike = {}) {
|
||||
const sub = newUuid();
|
||||
return new OAuthProfileFactory({
|
||||
sub,
|
||||
name: 'Name',
|
||||
given_name: 'Given',
|
||||
family_name: 'Family',
|
||||
email: `oauth-${sub}@immich.cloud`,
|
||||
email_verified: true,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
@@ -34,3 +35,4 @@ export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
export type OAuthProfileLike = Partial<OAuthProfile>;
|
||||
|
||||
1
server/test/fixtures/auth.stub.ts
vendored
1
server/test/fixtures/auth.stub.ts
vendored
@@ -48,6 +48,7 @@ export const authStub = {
|
||||
showExif: true,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
albumId: null,
|
||||
expiresAt: null,
|
||||
password: null,
|
||||
userId: '42',
|
||||
|
||||
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
return { result };
|
||||
}
|
||||
|
||||
async newAlbum(dto: Insertable<AlbumTable>) {
|
||||
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
|
||||
const album = mediumFactory.albumInsert(dto);
|
||||
const result = await this.get(AlbumRepository).create(album, [], []);
|
||||
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
|
||||
return { album, result };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { newDate } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let database: Kysely<DB>;
|
||||
|
||||
const setup = () => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(MetadataRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(MetadataRepository.name, () => {
|
||||
describe('writeTags', () => {
|
||||
it('should write an empty description', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, { Description: '' });
|
||||
expect(readFileSync(sidecarFile).toString()).toEqual(expect.stringContaining('rdf:Description'));
|
||||
});
|
||||
|
||||
it('should write an empty tags list', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, { TagsList: [] });
|
||||
const fileContent = readFileSync(sidecarFile).toString();
|
||||
expect(fileContent).toEqual(expect.stringContaining('digiKam:TagsList'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<rdf:li/>'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should write tags', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, {
|
||||
Description: 'my-description',
|
||||
ImageDescription: 'my-image-description',
|
||||
DateTimeOriginal: newDate().toISOString(),
|
||||
GPSLatitude: 42,
|
||||
GPSLongitude: 69,
|
||||
Rating: 3,
|
||||
TagsList: ['tagA'],
|
||||
});
|
||||
|
||||
const fileContent = readFileSync(sidecarFile).toString();
|
||||
expect(fileContent).toEqual(expect.stringContaining('my-description'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('my-image-description'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('exif:DateTimeOriginal'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLatitude>42,0.0N</exif:GPSLatitude>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLongitude>69,0.0E</exif:GPSLongitude>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<xmp:Rating>3</xmp:Rating>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('tagA'));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AssetFileType, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(AssetMediaService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AccessRepository, AssetRepository, UserRepository],
|
||||
real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const file = mediumFactory.uploadFile();
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
},
|
||||
file,
|
||||
mediumFactory.uploadFile(),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add to a shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Individual,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const file = mediumFactory.uploadFile();
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, file);
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
|
||||
|
||||
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
|
||||
const assets = update!.assets;
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatchObject({ id: response.id });
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to a shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Individual,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
|
||||
|
||||
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
|
||||
const assets = update!.assets;
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatchObject({ id: response.id });
|
||||
});
|
||||
|
||||
it('should add to an album shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
|
||||
|
||||
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to an album shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
|
||||
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
|
||||
|
||||
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
|
||||
@@ -47,15 +47,15 @@ describe(UserService.name, () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const user = mediumFactory.userInsert();
|
||||
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
|
||||
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists');
|
||||
});
|
||||
|
||||
it('should not return password', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const dto = mediumFactory.userInsert({ password: 'password' });
|
||||
const user = await sut.createUser({ email: dto.email, password: 'password' });
|
||||
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
|
||||
expect((user as any).password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
|
||||
expiresAt = null,
|
||||
userId = newUuid(),
|
||||
showExif = true,
|
||||
albumId = null,
|
||||
allowUpload = false,
|
||||
allowDownload = true,
|
||||
password = null,
|
||||
} = sharedLink;
|
||||
|
||||
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
|
||||
return {
|
||||
id,
|
||||
albumId,
|
||||
expiresAt,
|
||||
userId,
|
||||
showExif,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
password,
|
||||
};
|
||||
};
|
||||
|
||||
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.64.0",
|
||||
"@immich/ui": "^0.65.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.53.7",
|
||||
"svelte": "5.53.13",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { sendTestEmailAdmin } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
loading={isSending}
|
||||
disabled={!configToEdit.notifications.smtp.enabled}
|
||||
onclick={handleSendTestEmail}
|
||||
>
|
||||
@@ -151,9 +152,6 @@
|
||||
{$t('admin.notification_email_sent_test_email_button')}
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isSending}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
@@ -33,13 +33,11 @@
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
class="outline-none border-b max-h-32 border-transparent pl-0 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
variant="ghost"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
class="max-h-32"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
|
||||
@@ -3,59 +3,56 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { tv } from 'tailwind-variants';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
isOwned: boolean;
|
||||
onUpdate: (albumName: string) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
|
||||
|
||||
let newAlbumName = $derived(albumName);
|
||||
|
||||
const handleUpdateName = async () => {
|
||||
const handleUpdate = async () => {
|
||||
newAlbumName = newAlbumName.replaceAll('\n', ' ').trim();
|
||||
|
||||
if (newAlbumName === albumName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
albumName: newAlbumName,
|
||||
},
|
||||
});
|
||||
const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
|
||||
({ albumName } = response);
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
onUpdate(albumName);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const styles = tv({
|
||||
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
|
||||
variants: {
|
||||
isOwned: {
|
||||
true: 'hover:border-gray-400',
|
||||
false: 'hover:border-transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
const textClasses = 'text-2xl lg:text-6xl text-primary';
|
||||
</script>
|
||||
|
||||
<input
|
||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
||||
onblur={handleUpdateName}
|
||||
class={styles({ isOwned })}
|
||||
type="text"
|
||||
bind:value={newAlbumName}
|
||||
disabled={!isOwned}
|
||||
title={$t('edit_title')}
|
||||
placeholder={$t('add_a_title')}
|
||||
/>
|
||||
<div class="mb-2">
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={newAlbumName}
|
||||
variant="ghost"
|
||||
title={$t('edit_title')}
|
||||
onblur={handleUpdate}
|
||||
placeholder={$t('add_a_title')}
|
||||
class={textClasses}
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: (event) => event.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else}
|
||||
<div class={textClasses}>{newAlbumName}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -212,12 +212,12 @@
|
||||
bottom: `${rootHeight - top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${boundary.width}px`,
|
||||
maxHeight: maxHeight(top - dropdownOffset),
|
||||
maxHeight: maxHeight(boundary.top - dropdownOffset),
|
||||
};
|
||||
}
|
||||
|
||||
const viewportHeight = visualViewport?.height || rootHeight;
|
||||
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom;
|
||||
const viewportHeight = visualViewport?.height || window.innerHeight;
|
||||
const availableHeight = viewportHeight - boundary.bottom;
|
||||
return {
|
||||
top: `${bottom}px`,
|
||||
left: `${left}px`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { cancelUploadRequests } from '$lib/utils';
|
||||
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
|
||||
|
||||
class UploadManager {
|
||||
@@ -13,6 +14,7 @@ class UploadManager {
|
||||
}
|
||||
|
||||
reset() {
|
||||
cancelUploadRequests();
|
||||
uploadAssetsStore.reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_album'));
|
||||
handleError(error, $t('errors.unable_to_delete_album'), { notify });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,17 +78,40 @@ export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
let unsubscribeId = 0;
|
||||
const uploads: Record<number, () => void> = {};
|
||||
|
||||
const trackUpload = (unsubscribe: () => void) => {
|
||||
const id = unsubscribeId++;
|
||||
uploads[id] = unsubscribe;
|
||||
return () => {
|
||||
delete uploads[id];
|
||||
};
|
||||
};
|
||||
|
||||
export const cancelUploadRequests = () => {
|
||||
for (const unsubscribe of Object.values(uploads)) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
|
||||
const { onUploadProgress: onProgress, data, url } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const unsubscribe = trackUpload(() => xhr.abort());
|
||||
|
||||
xhr.addEventListener('error', (error) => {
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', (error) => reject(error));
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
|
||||
unsubscribe();
|
||||
resolve({ data: xhr.response as T, status: xhr.status });
|
||||
} else {
|
||||
unsubscribe();
|
||||
reject(new ApiError(xhr.statusText, xhr.status, xhr.response));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ async function fileUploader({
|
||||
uploadAssetsStore.track('success');
|
||||
}
|
||||
|
||||
if (albumId) {
|
||||
if (albumId && !authManager.isSharedLink) {
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
|
||||
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
|
||||
|
||||
@@ -23,7 +23,8 @@ export function standardizeError(error: unknown) {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export function handleError(error: unknown, localizedMessage: string) {
|
||||
export function handleError(error: unknown, localizedMessage: string, options?: { notify?: boolean }) {
|
||||
const { notify = true } = options ?? {};
|
||||
const standardizedError = standardizeError(error);
|
||||
if (standardizedError.name === 'AbortError') {
|
||||
return;
|
||||
@@ -39,7 +40,9 @@ export function handleError(error: unknown, localizedMessage: string) {
|
||||
|
||||
const errorMessage = serverMessage || localizedMessage;
|
||||
|
||||
toastManager.danger(errorMessage);
|
||||
if (notify) {
|
||||
toastManager.danger(errorMessage);
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
} catch (error) {
|
||||
|
||||
@@ -287,7 +287,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumAddAssets = async () => {
|
||||
const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[] }) => {
|
||||
if (!albumIds.includes(album.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshAlbum();
|
||||
timelineInteraction.clearMultiselect();
|
||||
await setModeToView();
|
||||
|
||||
Reference in New Issue
Block a user