Compare commits

...

26 Commits

Author SHA1 Message Date
Alex Tran
edac67acd2 update e2e package 2025-04-10 09:29:32 -05:00
Alex Tran
f410b58035 chore: update exiftool-vendor 2025-04-09 13:47:16 -05:00
Jason Rasmussen
8943ec23ba refactor: more database types (#17490) 2025-04-09 10:24:38 -04:00
Gagan Yadav
04b03f2924 fix(mobile): asset grid will infinitely scroll on iOS when select and… (#17469)
fix(mobile): asset grid will infinitely scroll on iOS when select and drag
2025-04-09 08:36:27 -05:00
Jason Rasmussen
cf2c0260a6 refactor: activity item (#17470)
* refactor: activity item

* fix query

* qualified columns

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-04-09 08:35:20 -04:00
Alex
ae8af84101 fix: no thumbnail generated for motion assets (#17472) 2025-04-08 16:07:10 -05:00
Jason Rasmussen
4794eeca88 refactor: database types (#17468) 2025-04-08 12:40:03 -04:00
Gagan Yadav
ac65d46ec6 fix(mobile): adds support for Internationalized Domain Name (IDN) (#17461) 2025-04-08 11:04:42 -05:00
Alex
e5ca79dd44 refactor: remove session entity (#17466)
* refactor: remove session entity

* fix: test

* update sql

* remote export
2025-04-08 16:04:07 +00:00
Jason Rasmussen
49be6d7fd8 refactor: more database enums (#17465) 2025-04-08 12:02:05 -04:00
Daniel Dietzler
15c6506aee fix: broken start/end dates on album update (#17467) 2025-04-08 15:47:44 +00:00
Jason Rasmussen
2c31a11e41 chore: replace generated enums with actual types (#17463) 2025-04-08 11:13:46 -04:00
Jason Rasmussen
b6c5a03533 refactor: remove tag entity (#17462) 2025-04-08 10:52:54 -04:00
Gagan Yadav
75bc32b47b fix(mobile): hide asset description text field if user is not owner (#17442)
* fix(mobile): hide asset description text field if user is not owner

* If user is not the owner and asset has no description then hide the text field

* Apply suggestions from code review

Co-authored-by: Alex <alex.tran1502@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-08 09:18:33 -05:00
Jason Rasmussen
fdbe6d649f refactor: remove smart search entity (#17447)
refactor: smart search entity
2025-04-08 09:56:45 -04:00
Aleksandr
2b131fe935 feat: opt-in sync of deletes and restores from web to Android (#16732)
* Features: Local file movement to trash and restoration back to the album added. (Android)

* Comments fixes

* settings button marked as [EXPERIMENTAL]

* _moveToTrashMatchedAssets refactored, moveToTrash renamed.

* fix: bad merge

* Permission check and request for local storage added.

* Permission request added on settings switcher

* Settings button logic changed

* Method channel file_trash moved to BackgroundServicePlugin

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-08 08:50:40 -05:00
snek
6ae24fbbd4 feat(web): improve individual share ux (#17430) 2025-04-08 09:11:37 -04:00
renovate[bot]
7f116d8e98 chore(deps): update mcr.microsoft.com/devcontainers/typescript-node:22 docker digest to b0b88ef (#17453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:32:14 +01:00
renovate[bot]
bd0840c411 chore(deps): update github/codeql-action digest to 45775bd (#17452)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:31:57 +01:00
renovate[bot]
a5123dec1a chore(deps): update grafana/grafana docker tag to v11.6.0 (#17460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:31:46 +01:00
renovate[bot]
ffd18c5459 chore(deps): update dependency @types/node to ^22.14.0 (#17459)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 12:14:30 +02:00
PyKen
8242ff9bab fix(server): Exclude album assets in shared link payload (#17207)
* fix(server): Exclude album assets in shared link payload

* Fix e2e test
2025-04-08 00:19:06 -04:00
Jason Rasmussen
8203b6c450 refactor: stop using geodata entity type (#17444) 2025-04-08 00:15:43 -04:00
Jason Rasmussen
b352cf3336 refactor: remove natural earth countries enity (#17445) 2025-04-08 00:15:16 -04:00
bo0tzz
96ed9a8c4a fix: restore mangled footnotes (#17446)
I broke this in #17257
2025-04-07 18:03:32 -04:00
Jason Rasmussen
e7a5b96ed0 feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269)
feat: sql-tools extension, triggers, functions, comments, parameters
2025-04-07 15:12:12 -04:00
249 changed files with 6512 additions and 2954 deletions

View File

@@ -1,4 +1,4 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:2ef23730ec68d8511ec8e6e0b82550ca728b256805d81f60ed890f3bfb21cfb9
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:b0b88ef6a5abf21194343d2c5b2829dddd9be1142f65f6a5e4390a51d5a70dd8
FROM ${BASEIMAGE}
# Flutter SDK

View File

@@ -46,7 +46,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3
uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3
uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,6 +72,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3
uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3
with:
category: '/language:${{matrix.language}}'

View File

@@ -518,7 +518,7 @@ jobs:
run: npm run build
- name: Run existing migrations
run: npm run typeorm:migrations:run
run: npm run migrations:run
- name: Test npm run schema:reset command works
run: npm run typeorm:schema:reset
@@ -532,7 +532,7 @@ jobs:
id: verify-changed-files
with:
files: |
server/src/migrations/
server/src
- name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |

18
cli/package-lock.json generated
View File

@@ -27,7 +27,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -61,7 +61,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"typescript": "^5.3.3"
}
},
@@ -1362,13 +1362,13 @@
}
},
"node_modules/@types/node": {
"version": "22.13.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==",
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -4073,9 +4073,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -102,7 +102,7 @@ services:
command: [ './run.sh', '-disable-reporting' ]
ports:
- 3000:3000
image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb
image: grafana/grafana:11.6.0-ubuntu@sha256:fd8fa48213c624e1a95122f1d93abbf1cf1cbe85fc73212c1e599dbd76c63ff8
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -70,3 +70,6 @@ If you get an error `can't set healthcheck.start_interval as feature require Doc
## Next Steps
Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md).
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env

View File

@@ -24,9 +24,6 @@ To clean up disk space, the old version's obsolete container images can be delet
docker image prune
```
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
[watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry
[releases]: https://github.com/immich-app/immich/releases

61
e2e/package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -25,7 +25,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1",
"exiftool-vendored": "^29.3.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
@@ -66,7 +66,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -100,7 +100,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"typescript": "^5.3.3"
}
},
@@ -1071,9 +1071,9 @@
}
},
"node_modules/@photostructure/tz-lookup": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz",
"integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.0.tgz",
"integrity": "sha512-DwrvodcXHNSdGdeSF7SBL5o8aBlsaeuCuG7633F04nYsL3hn5Hxe3z/5kCqxv61J1q7ggKZ27GPylR3x0cPNXQ==",
"dev": true,
"license": "CC0-1.0"
},
@@ -1585,13 +1585,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "22.13.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==",
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -3312,27 +3312,27 @@
}
},
"node_modules/exiftool-vendored": {
"version": "28.8.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
"integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==",
"version": "29.3.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-29.3.0.tgz",
"integrity": "sha512-2N+QvQH3mH0yb89vpxJXaD+SXa8GXvDigytS6cro6FOrTx9Opav4H0QPP0V4r9dBhXy5poON7qo+p1KZv5wZqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2",
"@photostructure/tz-lookup": "^11.2.0",
"@types/luxon": "^3.6.0",
"batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.5.0"
"luxon": "^3.6.1"
},
"optionalDependencies": {
"exiftool-vendored.exe": "13.0.0",
"exiftool-vendored.pl": "13.0.1"
"exiftool-vendored.exe": "13.26.0",
"exiftool-vendored.pl": "13.26.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz",
"integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==",
"version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.26.0.tgz",
"integrity": "sha512-y5mLmNAeABbXhb1a77EeR+CYDELI64DawnNywhMiivIYIdBPXf/81UcBd3SQlcTAHJjJjKt20qdmdL4loMkRDA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3341,15 +3341,18 @@
]
},
"node_modules/exiftool-vendored.pl": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz",
"integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==",
"version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.26.0.tgz",
"integrity": "sha512-4L8b6TrZcrd/dOnoeyCgsIa4WgFygucd0KQzB7xgWpgeMDQ2xYeqAYoHTeKmLAv4DogvaVkZQgDNogscuKuM+Q==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"!win32"
]
],
"bin": {
"exiftool": "bin/exiftool"
}
},
"node_modules/expect-type": {
"version": "1.2.1",
@@ -6316,9 +6319,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -35,7 +35,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1",
"exiftool-vendored": "^29.3.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",

View File

@@ -246,15 +246,7 @@ describe('/shared-links', () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.assets[0]).toEqual(
expect.objectContaining({
originalFileName: 'example.png',
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
exifInfo: expect.any(Object),
}),
);
expect(body.assets).toHaveLength(0);
expect(body.album).toBeDefined();
});

View File

@@ -6,6 +6,7 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@@ -124,4 +125,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -1,25 +1,40 @@
package app.alextran.immich
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest
import java.io.FileInputStream
import kotlinx.coroutines.*
/**
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
* Android plugin for Dart `BackgroundService` and file trash operations
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null
private var pendingResult: Result? = null
private val PERMISSION_REQUEST_CODE = 1001
private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
fileTrashChannel?.setMethodCallHandler(null)
fileTrashChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!!
when (call.method) {
// Existing BackgroundService methods
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
// File Trash methods moved from MainActivity
"moveToTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = moveToTrash(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = untrashImage(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"requestManageStoragePermission" -> {
if (!hasManageStoragePermission()) {
requestManageStoragePermission(result)
} else {
Log.e("Manage storage permission", "Permission already granted")
result.success(true)
}
}
else -> result.notImplemented()
}
}
// File Trash methods moved from MainActivity
private fun hasManageStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
true
}
}
private fun requestManageStoragePermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
} else {
result.success(true)
}
}
private fun moveToTrash(fileName: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getFileUri(fileName)
Log.e("FILE_URI", uri.toString())
return uri?.let { moveToTrash(it) } ?: false
}
private fun moveToTrash(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error moving to trash", e)
false
}
}
private fun getFileUri(fileName: String): Uri? {
val contentResolver = context?.contentResolver ?: return null
val contentUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(MediaStore.Images.Media._ID)
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(fileName)
var fileUri: Uri? = null
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
fileUri = ContentUris.withAppendedId(contentUri, id)
}
}
return fileUri
}
private fun untrashImage(name: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getTrashedFileUri(contentResolver, name)
Log.e("FILE_URI", uri.toString())
return uri?.let { untrashImage(it) } ?: false
}
private fun untrashImage(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error restoring file", e)
false
}
}
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
return ContentUris.withAppendedId(contentUri, id)
}
}
return null
}
// ActivityAware implementation
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
// ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
val granted = hasManageStoragePermission()
pendingResult?.success(granted)
pendingResult = null
return true
}
return false
}
}
private const val TAG = "BackgroundServicePlugin"
private const val BUFFER_SIZE = 2 * 1024 * 1024;
private const val BUFFER_SIZE = 2 * 1024 * 1024

View File

@@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
import androidx.annotation.NonNull
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
// No need to set up method channel here as it's now handled in the plugin
}
}

View File

@@ -23,6 +23,8 @@
"advanced_settings_tile_title": "Advanced",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"album_info_card_backup_album_excluded": "EXCLUDED",
"album_info_card_backup_album_included": "INCLUDED",
"albums": "Albums",

View File

@@ -65,6 +65,7 @@ enum StoreKey<T> {
// Video settings
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);

View File

@@ -0,0 +1,5 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(String fileName);
Future<bool> restoreFromTrash(String fileName);
Future<bool> requestManageStoragePermission();
}

View File

@@ -23,6 +23,7 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
assetTrash,
}
class PendingChange {
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges);
}
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetTrash)
.toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges
.expand((a) => (a.value as List).map((e) => e.toString()))
.toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => trashChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);

View File

@@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider =
Provider((ref) => LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager {
@override
Future<bool> moveToTrash(String fileName) async {
return await LocalFilesManager.moveToTrash(fileName);
}
@override
Future<bool> restoreFromTrash(String fileName) async {
return await LocalFilesManager.restoreFromTrash(fileName);
}
@override
Future<bool> requestManageStoragePermission() async {
return await LocalFilesManager.requestManageStoragePermission();
}
}

View File

@@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
0,
),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -16,6 +17,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
@@ -25,6 +28,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/entity.service.dart';
@@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider),
),
@@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService(
this._hashService,
@@ -82,6 +91,8 @@ class SyncService {
this._userRepository,
this._userService,
this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository,
this._userApiRepository,
);
@@ -238,8 +249,19 @@ class SyncService {
return null;
}
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
final List<Asset> localAssets = await _assetRepository.getAllLocal();
final List<Asset> matchedAssets = localAssets
.where((asset) => idsToDelete.contains(asset.remoteId))
.toList();
for (var asset in matchedAssets) {
_localFilesManager.moveToTrash(asset.fileName);
}
}
/// Deletes remote-only assets, updates merged assets to be local-only
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId(
idsToDelete,
@@ -249,6 +271,12 @@ class SyncService {
idsToDelete,
state: AssetState.merged,
);
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return;
for (final Asset asset in merged) {
asset.remoteId = null;
@@ -790,9 +818,27 @@ class SyncService {
return (existing, toUpsert);
}
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
for (var asset in assetsList) {
if (asset.isTrashed) {
_localFilesManager.moveToTrash(asset.fileName);
} else {
_localFilesManager.restoreFromTrash(asset.fileName);
}
}
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
try {
await _assetRepository.transaction(() async {

View File

@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class LocalFilesManager {
static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(String fileName) async {
try {
final bool success =
await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error moving to trash: ${e.message}');
return false;
}
}
static Future<bool> restoreFromTrash(String fileName) async {
try {
final bool success = await _channel
.invokeMethod('restoreFromTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error restoring file: ${e.message}');
return false;
}
}
static Future<bool> requestManageStoragePermission() async {
try {
final bool success =
await _channel.invokeMethod('requestManageStoragePermission');
return success;
} on PlatformException catch (e) {
debugPrint('Error requesting permission: ${e.message}');
return false;
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) {
// Add schema if none is set
@@ -11,13 +12,80 @@ String sanitizeUrl(String url) {
}
String? getServerUrl() {
final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;
}
return serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}";
return Uri.decodeFull(
serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}",
);
}
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
///
/// This is especially useful for internationalized domain names (IDNs),
/// where parts of the URL (typically the host) contain non-ASCII characters.
///
/// Example:
/// ```dart
/// final encodedUrl = punycodeEncodeUrl('https://bücher.de');
/// print(encodedUrl); // Outputs: https://xn--bcher-kva.de
/// ```
///
/// Notes:
/// - If the input URL is invalid, an empty string is returned.
/// - Only the host part of the URL is converted to Punycode; the scheme,
/// path, and port remain unchanged.
///
String punycodeEncodeUrl(String serverUrl) {
final serverUri = Uri.tryParse(serverUrl);
if (serverUri == null || serverUri.host.isEmpty) return '';
final encodedHost = Uri.decodeComponent(serverUri.host).split('.').map(
(segment) {
// If segment is already ASCII, then return as it is.
if (segment.runes.every((c) => c < 0x80)) return segment;
return 'xn--${punycodeEncode(segment)}';
},
).join('.');
return serverUri.replace(host: encodedHost).toString();
}
/// Decodes an ASCII-compatible (Punycode) URL back to its original Unicode representation.
///
/// This method is useful for converting internationalized domain names (IDNs)
/// that were previously encoded with Punycode back to their human-readable Unicode form.
///
/// Example:
/// ```dart
/// final decodedUrl = punycodeDecodeUrl('https://xn--bcher-kva.de');
/// print(decodedUrl); // Outputs: https://bücher.de
/// ```
///
/// Notes:
/// - If the input URL is invalid the method returns `null`.
/// - Only the host part of the URL is decoded. The scheme and port (if any) are preserved.
/// - The method assumes that the input URL only contains: scheme, host, port (optional).
/// - Query parameters, fragments, and user info are not handled (by design, as per constraints).
///
String? punycodeDecodeUrl(String? serverUrl) {
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null || serverUri.host.isEmpty) return null;
final decodedHost = serverUri.host.split('.').map(
(segment) {
if (segment.toLowerCase().startsWith('xn--')) {
return punycodeDecode(segment.substring(4));
}
// If segment is not punycode encoded, then return as it is.
return segment;
},
).join('.');
return Uri.decodeFull(serverUri.replace(host: decodedHost).toString());
}

View File

@@ -8,25 +8,25 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
@@ -107,6 +107,8 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
final Set<Asset> _draggedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
ScrollPhysics? _scrollPhysics;
Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets);
}
@@ -265,6 +267,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
physics: _scrollPhysics,
itemScrollController: _itemScrollController,
scrollOffsetController: _scrollOffsetController,
itemCount: widget.renderList.elements.length +
@@ -439,6 +442,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
void _setDragStartIndex(AssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorAssetIndex = index.rowIndex;
_dragAnchorSectionIndex = index.sectionIndex;
_dragging = true;
@@ -446,6 +450,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();

View File

@@ -34,17 +34,24 @@ class DescriptionInput extends HookConsumerWidget {
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
final assetWithExif = ref.watch(assetDetailProvider(asset));
final hasDescription = useState(false);
final isOwner = fastHash(owner?.id ?? '') == asset.ownerId;
useEffect(
() {
assetService
.getDescription(asset)
.then((value) => controller.text = value);
assetService.getDescription(asset).then((value) {
controller.text = value;
hasDescription.value = value.isNotEmpty;
});
return null;
},
[assetWithExif.value],
);
if (!isOwner && !hasDescription.value) {
return const SizedBox.shrink();
}
submitDescription(String description) async {
hasError.value = false;
try {
@@ -82,7 +89,7 @@ class DescriptionInput extends HookConsumerWidget {
}
return TextField(
enabled: fastHash(owner?.id ?? '') == asset.ownerId,
enabled: isOwner,
focusNode: focusNode,
onTap: () => isFocus.value = true,
onChanged: (value) {

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -7,18 +8,18 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
@@ -82,7 +83,8 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final serverUrl = sanitizeUrl(serverEndpointController.text);
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL
if (serverUrl.isEmpty) {

View File

@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
@@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
Future<bool> checkAndroidVersion() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 30;
}
return false;
}
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
@@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
FutureBuilder<bool>(
future: checkAndroidVersion(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref
.read(localFilesManagerRepositoryProvider)
.requestManageStoragePermission();
manageLocalMediaAndroid.value = result;
}
},
);
} else {
return const SizedBox.shrink();
}
},
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,

View File

@@ -51,6 +51,7 @@ dependencies:
permission_handler: ^11.4.0
photo_manager: ^3.6.4
photo_manager_image_provider: ^2.2.0
punycode: ^1.0.0
riverpod_annotation: ^2.6.1
scrollable_positioned_list: ^0.3.8
share_handler: ^0.0.22

View File

@@ -60,6 +60,9 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository =
MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository();
@@ -106,6 +109,8 @@ void main() {
userRepository,
userService,
eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository,
userApiRepository,
);

View File

@@ -0,0 +1,138 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/url_helper.dart';
void main() {
group('punycodeEncodeUrl', () {
test('should return empty string for invalid URL', () {
expect(punycodeEncodeUrl('not a url'), equals(''));
});
test('should handle empty input', () {
expect(punycodeEncodeUrl(''), equals(''));
});
test('should return ASCII-only URL unchanged', () {
const url = 'https://example.com';
expect(punycodeEncodeUrl(url), equals(url));
});
test('should encode single-segment Unicode host', () {
const url = 'https://bücher';
const expected = 'https://xn--bcher-kva';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should encode multi-segment Unicode host', () {
const url = 'https://bücher.de';
const expected = 'https://xn--bcher-kva.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test(
'should encode multi-segment Unicode host with multiple non-ASCII segments',
() {
const url = 'https://bücher.münchen';
const expected = 'https://xn--bcher-kva.xn--mnchen-3ya';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle URL with port', () {
const url = 'https://bücher.de:8080';
const expected = 'https://xn--bcher-kva.de:8080';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle URL with path', () {
const url = 'https://bücher.de/path/to/resource';
const expected = 'https://xn--bcher-kva.de/path/to/resource';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle URL with port and path', () {
const url = 'https://bücher.de:3000/path';
const expected = 'https://xn--bcher-kva.de:3000/path';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should not encode ASCII segment in multi-segment host', () {
const url = 'https://shop.bücher.de';
const expected = 'https://shop.xn--bcher-kva.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle host with hyphen in Unicode segment', () {
const url = 'https://bü-cher.de';
const expected = 'https://xn--b-cher-3ya.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle host with numbers in Unicode segment', () {
const url = 'https://bücher123.de';
const expected = 'https://xn--bcher123-65a.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should encode the domain of the original issue poster :)', () {
const url = 'https://фото.большойчлен.рф/';
const expected = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/';
expect(punycodeEncodeUrl(url), expected);
});
});
group('punycodeDecodeUrl', () {
test('should return null for null input', () {
expect(punycodeDecodeUrl(null), isNull);
});
test('should return null for an invalid URL', () {
// "not a url" should fail to parse.
expect(punycodeDecodeUrl('not a url'), isNull);
});
test('should return null for a URL with empty host', () {
// "https://" is a valid scheme but with no host.
expect(punycodeDecodeUrl('https://'), isNull);
});
test('should return ASCII-only URL unchanged', () {
const url = 'https://example.com';
expect(punycodeDecodeUrl(url), equals(url));
});
test('should decode a single-segment Punycode domain', () {
const input = 'https://xn--bcher-kva.de';
const expected = 'https://bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode a multi-segment Punycode domain', () {
const input = 'https://shop.xn--bcher-kva.de';
const expected = 'https://shop.bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode URL with port', () {
const input = 'https://xn--bcher-kva.de:8080';
const expected = 'https://bücher.de:8080';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode domains with uppercase punycode prefix correctly', () {
const input = 'https://XN--BCHER-KVA.de';
const expected = 'https://bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should handle mixed segments with no punycode in some parts', () {
const input = 'https://news.xn--bcher-kva.de';
const expected = 'https://news.bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode the domain of the original issue poster :)', () {
const url = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/';
const expected = 'https://фото.большойчлен.рф/';
expect(punycodeDecodeUrl(url), expected);
});
});
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart';
@@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockLocalFilesManagerRepository extends Mock
implements ILocalFilesManager {}

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
@@ -25,4 +26,7 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {}

View File

@@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"typescript": "^5.3.3"
}
},
@@ -23,13 +23,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==",
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
@@ -47,9 +47,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"typescript": "^5.3.3"
},
"repository": {

View File

@@ -34,7 +34,7 @@
"class-validator": "^0.14.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1",
"exiftool-vendored": "^29.3.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
@@ -90,7 +90,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",
@@ -4479,9 +4479,9 @@
}
},
"node_modules/@photostructure/tz-lookup": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.1.0.tgz",
"integrity": "sha512-UywyhMwUdVU2aH5ls7EweTEyPpXbDkgC//Nnsm/lWfpae8WX3N33Yy0/aBmb/Pd9+qEtgcFMYTtN/Htb+cd0ZA==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.0.tgz",
"integrity": "sha512-DwrvodcXHNSdGdeSF7SBL5o8aBlsaeuCuG7633F04nYsL3hn5Hxe3z/5kCqxv61J1q7ggKZ27GPylR3x0cPNXQ==",
"license": "CC0-1.0"
},
"node_modules/@pkgjs/parseargs": {
@@ -5825,12 +5825,12 @@
}
},
"node_modules/@types/node": {
"version": "22.13.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==",
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-fetch": {
@@ -9365,26 +9365,26 @@
}
},
"node_modules/exiftool-vendored": {
"version": "28.8.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
"integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==",
"version": "29.3.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-29.3.0.tgz",
"integrity": "sha512-2N+QvQH3mH0yb89vpxJXaD+SXa8GXvDigytS6cro6FOrTx9Opav4H0QPP0V4r9dBhXy5poON7qo+p1KZv5wZqQ==",
"license": "MIT",
"dependencies": {
"@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2",
"@photostructure/tz-lookup": "^11.2.0",
"@types/luxon": "^3.6.0",
"batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.5.0"
"luxon": "^3.6.1"
},
"optionalDependencies": {
"exiftool-vendored.exe": "13.0.0",
"exiftool-vendored.pl": "13.0.1"
"exiftool-vendored.exe": "13.26.0",
"exiftool-vendored.pl": "13.26.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz",
"integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==",
"version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.26.0.tgz",
"integrity": "sha512-y5mLmNAeABbXhb1a77EeR+CYDELI64DawnNywhMiivIYIdBPXf/81UcBd3SQlcTAHJjJjKt20qdmdL4loMkRDA==",
"license": "MIT",
"optional": true,
"os": [
@@ -9392,14 +9392,23 @@
]
},
"node_modules/exiftool-vendored.pl": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz",
"integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==",
"version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.26.0.tgz",
"integrity": "sha512-4L8b6TrZcrd/dOnoeyCgsIa4WgFygucd0KQzB7xgWpgeMDQ2xYeqAYoHTeKmLAv4DogvaVkZQgDNogscuKuM+Q==",
"license": "MIT",
"optional": true,
"os": [
"!win32"
]
],
"bin": {
"exiftool": "bin/exiftool"
}
},
"node_modules/exiftool-vendored/node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.2.0",
@@ -16736,9 +16745,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unicorn-magic": {

View File

@@ -25,10 +25,10 @@
"lifecycle": "node ./dist/utils/lifecycle.js",
"migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "node ./dist/bin/migrations.js create",
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js",
"migrations:run": "node ./dist/bin/migrations.js run",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js",
"typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'",
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run migrations:run",
"kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
@@ -60,7 +60,7 @@
"class-validator": "^0.14.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1",
"exiftool-vendored": "^29.3.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
@@ -116,7 +116,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -1,15 +1,20 @@
#!/usr/bin/env node
process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich';
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository';
import 'src/schema/tables';
import { DatabaseTable, schemaDiff, schemaFromDatabase, schemaFromDecorators } from 'src/sql-tools';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
const main = async () => {
const command = process.argv[2];
const name = process.argv[3] || 'Migration';
const path = process.argv[3] || 'src/Migration';
switch (command) {
case 'debug': {
@@ -17,13 +22,19 @@ const main = async () => {
return;
}
case 'run': {
const only = process.argv[3] as 'kysely' | 'typeorm' | undefined;
await run(only);
return;
}
case 'create': {
create(name, [], []);
create(path, [], []);
return;
}
case 'generate': {
await generate(name);
await generate(path);
return;
}
@@ -31,32 +42,57 @@ const main = async () => {
console.log(`Usage:
node dist/bin/migrations.js create <name>
node dist/bin/migrations.js generate <name>
node dist/bin/migrations.js run
`);
}
}
};
const run = async (only?: 'kysely' | 'typeorm') => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const logger = new LoggingRepository(undefined, configRepository);
const db = new Kysely<any>({
dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }),
log(event) {
if (event.level === 'error') {
console.error('Query failed :', {
durationMs: event.queryDurationMillis,
error: event.error,
sql: event.query.sql,
params: event.query.parameters,
});
}
},
});
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
await databaseRepository.runMigrations({ only });
};
const debug = async () => {
const { up, down } = await compare();
const { up } = await compare();
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
writeFileSync('./migrations.sql', upSql + '\n\n' + downSql);
// const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
writeFileSync('./migrations.sql', upSql + '\n\n');
console.log('Wrote migrations.sql');
};
const generate = async (name: string) => {
const generate = async (path: string) => {
const { up, down } = await compare();
if (up.items.length === 0) {
console.log('No changes detected');
return;
}
create(name, up.asSql(), down.asSql());
create(path, up.asSql(), down.asSql());
};
const create = (name: string, up: string[], down: string[]) => {
const create = (path: string, up: string[], down: string[]) => {
const timestamp = Date.now();
const name = basename(path, extname(path));
const filename = `${timestamp}-${name}.ts`;
const fullPath = `./src/${filename}`;
const folder = dirname(path);
const fullPath = join(folder, filename);
writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down }));
console.log(`Wrote ${fullPath}`);
};
@@ -66,16 +102,25 @@ const compare = async () => {
const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely);
const source = schemaFromDecorators();
const source = schemaFromCode();
const target = await schemaFromDatabase(db, {});
const sourceParams = new Set(source.parameters.map(({ name }) => name));
target.parameters = target.parameters.filter(({ name }) => sourceParams.has(name));
const sourceTables = new Set(source.tables.map(({ name }) => name));
target.tables = target.tables.filter(({ name }) => sourceTables.has(name));
console.log(source.warnings.join('\n'));
const isIncluded = (table: DatabaseTable) => source.tables.some(({ name }) => table.name === name);
target.tables = target.tables.filter((table) => isIncluded(table));
const up = schemaDiff(source, target, { ignoreExtraTables: true });
const down = schemaDiff(target, source, { ignoreExtraTables: false });
const up = schemaDiff(source, target, {
tables: { ignoreExtra: true },
functions: { ignoreExtra: false },
});
const down = schemaDiff(target, source, {
tables: { ignoreExtra: false },
functions: { ignoreExtra: false },
});
return { up, down };
};

View File

@@ -1,5 +1,6 @@
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { AssetStatus, AssetType, Permission, UserStatus } from 'src/enum';
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { OnThisDayData } from 'src/types';
export type AuthUser = {
id: string;
@@ -29,6 +30,19 @@ export type AuthApiKey = {
permissions: Permission[];
};
export type Activity = {
id: string;
createdAt: Date;
updatedAt: Date;
albumId: string;
userId: string;
user: User;
assetId: string | null;
comment: string | null;
isLiked: boolean;
updateId: string;
};
export type ApiKey = {
id: string;
name: string;
@@ -38,6 +52,31 @@ export type ApiKey = {
permissions: Permission[];
};
export type Tag = {
id: string;
value: string;
createdAt: Date;
updatedAt: Date;
color: string | null;
parentId: string | null;
};
export type Memory = {
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
memoryAt: Date;
seenAt: Date | null;
showAt: Date | null;
hideAt: Date | null;
type: MemoryType;
data: OnThisDayData;
ownerId: string;
isSaved: boolean;
assets: Asset[];
};
export type User = {
id: string;
name: string;
@@ -92,6 +131,13 @@ export type Asset = {
type: AssetType;
};
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type AuthSharedLink = {
id: string;
expiresAt: Date | null;
@@ -117,6 +163,28 @@ export type Partner = {
inTimeline: boolean;
};
export type Place = {
admin1Code: string | null;
admin1Name: string | null;
admin2Code: string | null;
admin2Name: string | null;
alternateNames: string | null;
countryCode: string;
id: number;
latitude: number;
longitude: number;
modificationDate: Date;
name: string;
};
export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
deviceOS: string;
deviceType: string;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
export const columns = {
@@ -140,6 +208,7 @@ export const columns = {
'shared_links.password',
],
user: userColumns,
userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'],
userAdmin: [
...userColumns,
'createdAt',

29
server/src/db.d.ts vendored
View File

@@ -4,7 +4,18 @@
*/
import type { ColumnType } from 'kysely';
import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum';
import {
AlbumUserRole,
AssetFileType,
AssetOrder,
AssetStatus,
AssetType,
MemoryType,
Permission,
SharedLinkType,
SourceType,
SyncEntityType,
} from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import { OnThisDayData } from 'src/types';
@@ -12,8 +23,6 @@ export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTyp
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed';
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@@ -31,8 +40,6 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = 'exif' | 'machine-learning' | 'manual';
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface Activity {
@@ -58,7 +65,7 @@ export interface Albums {
description: Generated<string>;
id: Generated<string>;
isActivityEnabled: Generated<boolean>;
order: Generated<string>;
order: Generated<AssetOrder>;
ownerId: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
@@ -72,7 +79,7 @@ export interface AlbumsAssetsAssets {
export interface AlbumsSharedUsersUsers {
albumsId: string;
role: Generated<string>;
role: Generated<AlbumUserRole>;
usersId: string;
}
@@ -98,7 +105,7 @@ export interface AssetFaces {
imageHeight: Generated<number>;
imageWidth: Generated<number>;
personId: string | null;
sourceType: Generated<Sourcetype>;
sourceType: Generated<SourceType>;
}
export interface AssetFiles {
@@ -106,7 +113,7 @@ export interface AssetFiles {
createdAt: Generated<Timestamp>;
id: Generated<string>;
path: string;
type: string;
type: AssetFileType;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
@@ -152,7 +159,7 @@ export interface Assets {
ownerId: string;
sidecarPath: string | null;
stackId: string | null;
status: Generated<AssetsStatusEnum>;
status: Generated<AssetStatus>;
thumbhash: Buffer | null;
type: AssetType;
updatedAt: Generated<Timestamp>;
@@ -350,7 +357,7 @@ export interface SharedLinks {
key: Buffer;
password: string | null;
showExif: Generated<boolean>;
type: string;
type: SharedLinkType;
userId: string;
}

View File

@@ -4,8 +4,24 @@ import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
import { setUnion } from 'src/utils/set';
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` });
export const UpdateIdColumn = () => GeneratedUuidV7Column();
export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true });
export const UpdatedAtTrigger = (name: string) =>
BeforeUpdateTrigger({
name,
scope: 'row',
function: updated_at,
});
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching
// by a list of IDs) requires splitting the query into multiple chunks.

View File

@@ -1,8 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { Activity } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { ActivityItem } from 'src/types';
import { Optional, ValidateUUID } from 'src/validation';
export enum ReactionType {
@@ -68,7 +68,7 @@ export class ActivityCreateDto extends ActivityDto {
comment?: string;
}
export const mapActivity = (activity: ActivityItem): ActivityResponseDto => {
export const mapActivity = (activity: Activity): ActivityResponseDto => {
return {
id: activity.id,
assetId: activity.assetId,

View File

@@ -1,11 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { Memory } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum';
import { MemoryItem } from 'src/types';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class MemoryBaseDto {
@@ -89,7 +89,7 @@ export class MemoryResponseDto {
assets!: AssetResponseDto[];
}
export const mapMemory = (entity: MemoryItem, auth: AuthDto): MemoryResponseDto => {
export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
return {
id: entity.id,
createdAt: entity.createdAt,

View File

@@ -1,10 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { AssetOrder, AssetType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
@@ -226,15 +226,16 @@ export class PlacesResponseDto {
admin2name?: string;
}
export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto {
export function mapPlaces(place: Place): PlacesResponseDto {
return {
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
admin1name: place.admin1Name,
admin2name: place.admin2Name,
admin1name: place.admin1Name ?? undefined,
admin2name: place.admin2Name ?? undefined,
};
}
export enum SearchSuggestionType {
COUNTRY = 'country',
STATE = 'state',

View File

@@ -1,4 +1,4 @@
import { SessionItem } from 'src/types';
import { Session } from 'src/database';
export class SessionResponseDto {
id!: string;
@@ -9,7 +9,7 @@ export class SessionResponseDto {
deviceOS!: string;
}
export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({
export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),

View File

@@ -104,9 +104,6 @@ export class SharedLinkResponseDto {
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
@@ -117,7 +114,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset)),
assets: linkAssets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,

View File

@@ -1,7 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { TagItem } from 'src/types';
import { Tag } from 'src/database';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
export class TagCreateDto {
@@ -52,7 +51,7 @@ export class TagResponseDto {
color?: string;
}
export function mapTag(entity: TagItem | TagEntity): TagResponseDto {
export function mapTag(entity: Tag): TagResponseDto {
return {
id: entity.id,
parentId: entity.parentId ?? undefined,

View File

@@ -1,5 +1,6 @@
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Tag } from 'src/database';
import { DB } from 'src/db';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@@ -7,9 +8,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
@@ -50,8 +49,7 @@ export class AssetEntity {
originalFileName!: string;
sidecarPath!: string | null;
exifInfo?: ExifEntity;
smartSearch?: SmartSearchEntity;
tags!: TagEntity[];
tags?: Tag[];
sharedLinks!: SharedLinkEntity[];
albums?: AlbumEntity[];
faces!: AssetFaceEntity[];
@@ -97,9 +95,9 @@ export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileT
return jsonArrayFrom(
eb
.selectFrom('asset_files')
.selectAll()
.selectAll('asset_files')
.whereRef('asset_files.assetId', '=', 'assets.id')
.$if(!!type, (qb) => qb.where('type', '=', type!)),
.$if(!!type, (qb) => qb.where('asset_files.type', '=', type!)),
).as('files');
}

View File

@@ -1,13 +0,0 @@
export class GeodataPlacesEntity {
id!: number;
name!: string;
longitude!: number;
latitude!: number;
countryCode!: string;
admin1Code!: string;
admin2Code!: string;
admin1Name!: string;
admin2Name!: string;
alternateNames!: string;
modificationDate!: Date;
}

View File

@@ -1,7 +0,0 @@
export class NaturalEarthCountriesTempEntity {
id!: number;
admin!: string;
admin_a3!: string;
type!: string;
coordinates!: string;
}

View File

@@ -1,49 +0,0 @@
import { ExpressionBuilder } from 'kysely';
import { DB } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
export class SessionEntity {
id!: string;
token!: string;
userId!: string;
user!: UserEntity;
createdAt!: Date;
updatedAt!: Date;
updateId!: string;
deviceType!: string;
deviceOS!: string;
}
const userColumns = [
'id',
'email',
'createdAt',
'profileImagePath',
'isAdmin',
'shouldChangePassword',
'deletedAt',
'oauthId',
'updatedAt',
'storageLabel',
'name',
'quotaSizeInBytes',
'quotaUsageInBytes',
'status',
'profileChangedAt',
] as const;
export const withUser = (eb: ExpressionBuilder<DB, 'sessions'>) => {
return eb
.selectFrom('users')
.select(userColumns)
.select((eb) =>
eb
.selectFrom('user_metadata')
.whereRef('users.id', '=', 'user_metadata.userId')
.select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata'))
.as('metadata'),
)
.whereRef('users.id', '=', 'sessions.userId')
.where('users.deletedAt', 'is', null)
.as('user');
};

View File

@@ -1,7 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
export class SmartSearchEntity {
asset?: AssetEntity;
assetId!: string;
embedding!: string;
}

View File

@@ -1,17 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
export class TagEntity {
id!: string;
value!: string;
createdAt!: Date;
updatedAt!: Date;
updateId?: string;
color!: string | null;
parentId?: string;
parent?: TagEntity;
children?: TagEntity[];
user?: UserEntity;
userId!: string;
assets?: AssetEntity[];
}

View File

@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TableCleanup1743595393000 implements MigrationInterface {
name = 'TableCleanup1743595393000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "system_config"`);
await queryRunner.query(`DROP TABLE IF EXISTS "socket_io_attachments"`);
}
public async down(): Promise<void> {}
}

View File

@@ -3,6 +3,38 @@
-- ActivityRepository.search
select
"activity".*,
to_json("user") as "user"
from
"activity"
inner join "users" on "users"."id" = "activity"."userId"
and "users"."deletedAt" is null
inner join lateral (
select
"users"."id",
"users"."name",
"users"."email",
"users"."profileImagePath",
"users"."profileChangedAt"
from
(
select
1
) as "dummy"
) as "user" on true
left join "assets" on "assets"."id" = "activity"."assetId"
and "assets"."deletedAt" is null
where
"activity"."albumId" = $1
order by
"activity"."createdAt" asc
-- ActivityRepository.create
insert into
"activity" ("albumId", "userId")
values
($1, $2)
returning
*,
(
select
to_json(obj)
@@ -18,17 +50,13 @@ select
"users"
where
"users"."id" = "activity"."userId"
and "users"."deletedAt" is null
) as obj
) as "user"
from
"activity"
left join "assets" on "assets"."id" = "activity"."assetId"
and "assets"."deletedAt" is null
-- ActivityRepository.delete
delete from "activity"
where
"activity"."albumId" = $1
order by
"activity"."createdAt" asc
"id" = $1::uuid
-- ActivityRepository.getStatistics
select

View File

@@ -179,6 +179,63 @@ from
where
"livePhotoVideoId" = $1::uuid
-- AssetRepository.getAssetForSearchDuplicatesJob
select
"id",
"type",
"ownerId",
"duplicateId",
"stackId",
"isVisible",
"smart_search"."embedding",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_files".*
from
"asset_files"
where
"asset_files"."assetId" = "assets"."id"
and "asset_files"."type" = $1
) as agg
) as "files"
from
"assets"
left join "smart_search" on "assets"."id" = "smart_search"."assetId"
where
"assets"."id" = $2::uuid
limit
$3
-- AssetRepository.getAssetForSidecarWriteJob
select
"id",
"sidecarPath",
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"tags"."value"
from
"tags"
inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
where
"assets"."id" = "tag_asset"."assetsId"
) as agg
) as "tags"
from
"assets"
where
"assets"."id" = $1::uuid
limit
$2
-- AssetRepository.getById
select
"assets".*

View File

@@ -38,41 +38,11 @@ where
-- SessionRepository.getByUserId
select
"sessions".*,
to_json("user") as "user"
"sessions".*
from
"sessions"
inner join lateral (
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
(
select
array_agg("user_metadata") as "metadata"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as "metadata"
from
"users"
where
"users"."id" = "sessions"."userId"
and "users"."deletedAt" is null
) as "user" on true
inner join "users" on "users"."id" = "sessions"."userId"
and "users"."deletedAt" is null
where
"sessions"."userId" = $1
order by

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely } from 'kysely';
import { Insertable, Kysely, NotNull, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
@@ -14,16 +14,6 @@ export interface ActivitySearch {
isLiked?: boolean;
}
const withUser = (eb: ExpressionBuilder<DB, 'activity'>) => {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(columns.user)
.whereRef('users.id', '=', 'activity.userId')
.where('users.deletedAt', 'is', null),
).as('user');
};
@Injectable()
export class ActivityRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -35,7 +25,16 @@ export class ActivityRepository {
return this.db
.selectFrom('activity')
.selectAll('activity')
.select(withUser)
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
.innerJoinLateral(
(eb) =>
eb
.selectFrom(sql`(select 1)`.as('dummy'))
.select(columns.userWithPrefix)
.as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
.leftJoin('assets', (join) => join.onRef('assets.id', '=', 'activity.assetId').on('assets.deletedAt', 'is', null))
.$if(!!userId, (qb) => qb.where('activity.userId', '=', userId!))
.$if(assetId === null, (qb) => qb.where('assetId', 'is', null))
@@ -46,10 +45,22 @@ export class ActivityRepository {
.execute();
}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID }] })
async create(activity: Insertable<Activity>) {
return this.save(activity);
return this.db
.insertInto('activity')
.values(activity)
.returningAll()
.returning((eb) =>
jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'activity.userId').select(columns.user)).as(
'user',
),
)
.$narrowType<{ user: NotNull }>()
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string) {
await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute();
}
@@ -72,15 +83,4 @@ export class ActivityRepository {
return count as number;
}
private async save(entity: Insertable<Activity>) {
const { id } = await this.db.insertInto('activity').values(entity).returning('id').executeTakeFirstOrThrow();
return this.db
.selectFrom('activity')
.selectAll('activity')
.select(withUser)
.where('activity.id', '=', asUuid(id))
.executeTakeFirstOrThrow();
}
}

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
@@ -475,6 +476,47 @@ export class AssetRepository {
return count as number;
}
@GenerateSql({ params: [DummyValue.UUID] })
getAssetForSearchDuplicatesJob(id: string) {
return this.db
.selectFrom('assets')
.where('assets.id', '=', asUuid(id))
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
.select((eb) => [
'id',
'type',
'ownerId',
'duplicateId',
'stackId',
'isVisible',
'smart_search.embedding',
withFiles(eb, AssetFileType.PREVIEW),
])
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAssetForSidecarWriteJob(id: string) {
return this.db
.selectFrom('assets')
.where('assets.id', '=', asUuid(id))
.select((eb) => [
'id',
'sidecarPath',
'originalPath',
jsonArrayFrom(
eb
.selectFrom('tags')
.select(['tags.value'])
.innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
.whereRef('assets.id', '=', 'tag_asset.assetsId'),
).as('tags'),
])
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,

View File

@@ -197,58 +197,62 @@ export class DatabaseRepository {
return dimSize;
}
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> {
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each'; only?: 'kysely' | 'typeorm' }): Promise<void> {
const { database } = this.configRepository.getEnv();
const dataSource = new DataSource(database.config.typeorm);
if (options?.only !== 'kysely') {
const dataSource = new DataSource(database.config.typeorm);
this.logger.log('Running migrations, this may take a while');
this.logger.log('Running migrations, this may take a while');
this.logger.debug('Running typeorm migrations');
this.logger.debug('Running typeorm migrations');
await dataSource.initialize();
await dataSource.runMigrations(options);
await dataSource.destroy();
await dataSource.initialize();
await dataSource.runMigrations(options);
await dataSource.destroy();
this.logger.debug('Finished running typeorm migrations');
// eslint-disable-next-line unicorn/prefer-module
const migrationFolder = join(__dirname, '..', 'schema/migrations');
// TODO remove after we have at least one kysely migration
if (!existsSync(migrationFolder)) {
return;
this.logger.debug('Finished running typeorm migrations');
}
this.logger.debug('Running kysely migrations');
const migrator = new Migrator({
db: this.db,
migrationLockTableName: 'kysely_migrations_lock',
migrationTableName: 'kysely_migrations',
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
migrationFolder,
}),
});
if (options?.only !== 'typeorm') {
// eslint-disable-next-line unicorn/prefer-module
const migrationFolder = join(__dirname, '..', 'schema/migrations');
const { error, results } = await migrator.migrateToLatest();
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Migration "${result.migrationName}" succeeded`);
// TODO remove after we have at least one kysely migration
if (!existsSync(migrationFolder)) {
return;
}
if (result.status === 'Error') {
this.logger.warn(`Migration "${result.migrationName}" failed`);
this.logger.debug('Running kysely migrations');
const migrator = new Migrator({
db: this.db,
migrationLockTableName: 'kysely_migrations_lock',
migrationTableName: 'kysely_migrations',
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
migrationFolder,
}),
});
const { error, results } = await migrator.migrateToLatest();
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Migration "${result.migrationName}" succeeded`);
}
if (result.status === 'Error') {
this.logger.warn(`Migration "${result.migrationName}" failed`);
}
}
}
if (error) {
this.logger.error(`Kysely migrations failed: ${error}`);
throw error;
}
if (error) {
this.logger.error(`Kysely migrations failed: ${error}`);
throw error;
}
this.logger.debug('Finished running kysely migrations');
this.logger.debug('Finished running kysely migrations');
}
}
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {

View File

@@ -1,4 +1,4 @@
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { ClsService } from 'nestjs-cls';
import { Telemetry } from 'src/decorators';
@@ -26,7 +26,7 @@ export class MyConsoleLogger extends ConsoleLogger {
private isColorEnabled: boolean;
constructor(
private cls: ClsService,
private cls: ClsService | undefined,
options?: { color?: boolean; context?: string },
) {
super(options?.context || MyConsoleLogger.name);
@@ -74,7 +74,7 @@ export class MyConsoleLogger extends ConsoleLogger {
export class LoggingRepository {
private logger: MyConsoleLogger;
constructor(cls: ClsService, configRepository: ConfigRepository) {
constructor(@Inject(ClsService) cls: ClsService | undefined, configRepository: ConfigRepository) {
const { noColor } = configRepository.getEnv();
this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor });
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries';
import { Expression, Kysely, sql, SqlBool } from 'kysely';
import { Expression, Insertable, Kysely, sql, SqlBool } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
@@ -8,7 +8,6 @@ import readLine from 'node:readline';
import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -182,11 +181,11 @@ export class MapRepository {
return;
}
const entities: Omit<NaturalEarthCountriesTempEntity, 'id'>[] = [];
const entities: Insertable<NaturalearthCountries>[] = [];
for (const feature of geoJSONData.features) {
for (const entry of feature.geometry.coordinates) {
const coordinates: number[][][] = feature.geometry.type === 'MultiPolygon' ? entry[0] : entry;
const featureRecord: Omit<NaturalEarthCountriesTempEntity, 'id'> = {
const featureRecord: Insertable<NaturalearthCountries> = {
admin: feature.properties.ADMIN,
admin_a3: feature.properties.ADM0_A3,
type: feature.properties.TYPE,

View File

@@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { AssetStatus, AssetType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { anyUuid, asUuid } from 'src/utils/database';
@@ -372,7 +371,7 @@ export class SearchRepository {
}
@GenerateSql({ params: [DummyValue.STRING] })
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
searchPlaces(placeName: string) {
return this.db
.selectFrom('geodata_places')
.selectAll()
@@ -395,7 +394,7 @@ export class SearchRepository {
`,
)
.limit(20)
.execute() as Promise<GeodataPlacesEntity[]>;
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })

View File

@@ -5,7 +5,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, Sessions } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { withUser } from 'src/entities/session.entity';
import { asUuid } from 'src/utils/database';
export type SessionSearchOptions = { updatedBefore: Date };
@@ -45,9 +44,8 @@ export class SessionRepository {
getByUserId(userId: string) {
return this.db
.selectFrom('sessions')
.innerJoinLateral(withUser, (join) => join.onTrue())
.innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions')
.select((eb) => eb.fn.toJson('user').as('user'))
.where('sessions.userId', '=', userId)
.orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc')

View File

@@ -0,0 +1,12 @@
import { AssetStatus, SourceType } from 'src/enum';
import { registerEnum } from 'src/sql-tools';
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
values: Object.values(AssetStatus),
});
export const asset_face_source_type = registerEnum({
name: 'sourcetype',
values: Object.values(SourceType),
});

View File

@@ -0,0 +1,116 @@
import { registerFunction } from 'src/sql-tools';
export const immich_uuid_v7 = registerFunction({
name: 'immich_uuid_v7',
arguments: ['p_timestamp timestamp with time zone default clock_timestamp()'],
returnType: 'uuid',
language: 'SQL',
behavior: 'volatile',
body: `
SELECT encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
`,
synchronize: false,
});
export const updated_at = registerFunction({
name: 'updated_at',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
DECLARE
clock_timestamp TIMESTAMP := clock_timestamp();
BEGIN
new."updatedAt" = clock_timestamp;
new."updateId" = immich_uuid_v7(clock_timestamp);
return new;
END;`,
synchronize: false,
});
export const f_concat_ws = registerFunction({
name: 'f_concat_ws',
arguments: ['text', 'text[]'],
returnType: 'text',
language: 'SQL',
parallel: 'safe',
behavior: 'immutable',
body: `SELECT array_to_string($2, $1)`,
synchronize: false,
});
export const f_unaccent = registerFunction({
name: 'f_unaccent',
arguments: ['text'],
returnType: 'text',
language: 'SQL',
parallel: 'safe',
strict: true,
behavior: 'immutable',
return: `unaccent('unaccent', $1)`,
synchronize: false,
});
export const ll_to_earth_public = registerFunction({
name: 'll_to_earth_public',
arguments: ['latitude double precision', 'longitude double precision'],
returnType: 'public.earth',
language: 'SQL',
parallel: 'safe',
strict: true,
behavior: 'immutable',
body: `SELECT public.cube(public.cube(public.cube(public.earth()*cos(radians(latitude))*cos(radians(longitude))),public.earth()*cos(radians(latitude))*sin(radians(longitude))),public.earth()*sin(radians(latitude)))::public.earth`,
synchronize: false,
});
export const users_delete_audit = registerFunction({
name: 'users_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO users_audit ("userId")
SELECT "id"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});
export const partners_delete_audit = registerFunction({
name: 'partners_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO partners_audit ("sharedById", "sharedWithId")
SELECT "sharedById", "sharedWithId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});
export const assets_delete_audit = registerFunction({
name: 'assets_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO assets_audit ("assetId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});

109
server/src/schema/index.ts Normal file
View File

@@ -0,0 +1,109 @@
import { asset_face_source_type, assets_status_enum } from 'src/schema/enums';
import {
assets_delete_audit,
f_concat_ws,
f_unaccent,
immich_uuid_v7,
ll_to_earth_public,
partners_delete_audit,
updated_at,
users_delete_audit,
} from 'src/schema/functions';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { APIKeyTable } from 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-files.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { ExifTable } from 'src/schema/tables/exif.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { SmartSearchTable } from 'src/schema/tables/smart-search.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table';
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { TagClosureTable } from 'src/schema/tables/tag-closure.table';
import { UserAuditTable } from 'src/schema/tables/user-audit.table';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'vectors', 'plpgsql'])
@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
@ConfigurationParameter({
name: 'vectors.pgvector_compatibility',
value: () => 'on',
scope: 'user',
synchronize: false,
})
@Database({ name: 'immich' })
export class ImmichDatabase {
tables = [
ActivityTable,
AlbumAssetTable,
AlbumUserTable,
AlbumTable,
APIKeyTable,
AssetAuditTable,
AssetFaceTable,
AssetJobStatusTable,
AssetTable,
AssetFileTable,
AuditTable,
ExifTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MoveTable,
NaturalEarthCountriesTable,
PartnerAuditTable,
PartnerTable,
PersonTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
SmartSearchTable,
StackTable,
SessionSyncCheckpointTable,
SystemMetadataTable,
TagAssetTable,
TagClosureTable,
UserAuditTable,
UserMetadataTable,
UserTable,
VersionHistoryTable,
];
functions = [
immich_uuid_v7,
updated_at,
f_concat_ws,
f_unaccent,
ll_to_earth_public,
users_delete_audit,
partners_delete_audit,
assets_delete_audit,
];
enum = [assets_status_enum, asset_face_source_type];
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -11,10 +12,10 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('activity')
@UpdatedAtTrigger('activity_updated_at')
@Index({
name: 'IDX_activity_like',
columns: ['assetId', 'userId', 'albumId'],
@@ -35,9 +36,14 @@ export class ActivityTable {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_activity_update_id')
@UpdateIdColumn()
updateId!: string;
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
assetId!: string | null;
@Column({ type: 'text', default: null })
comment!: string | null;
@@ -45,12 +51,7 @@ export class ActivityTable {
@Column({ type: 'boolean', default: false })
isLiked!: boolean;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
assetId!: string | null;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@ColumnIndex('IDX_activity_update_id')
@UpdateIdColumn()
updateId!: string;
}

View File

@@ -4,15 +4,6 @@ import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
export class AlbumAssetTable {
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
@ColumnIndex()
assetsId!: string;
@ForeignKeyColumn(() => AlbumTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
@@ -22,6 +13,15 @@ export class AlbumAssetTable {
@ColumnIndex()
albumsId!: string;
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
@ColumnIndex()
assetsId!: string;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -10,10 +11,10 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' })
@UpdatedAtTrigger('albums_updated_at')
export class AlbumTable {
@PrimaryGeneratedColumn()
id!: string;
@@ -24,28 +25,33 @@ export class AlbumTable {
@Column({ default: 'Untitled Album' })
albumName!: string;
@Column({ type: 'text', default: '' })
description!: string;
@CreateDateColumn()
createdAt!: Date;
@ForeignKeyColumn(() => AssetTable, {
nullable: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
comment: 'Asset ID to be used as thumbnail',
})
albumThumbnailAssetId!: string;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_albums_update_id')
@UpdateIdColumn()
updateId?: string;
@Column({ type: 'text', default: '' })
description!: string;
@DeleteDateColumn()
deletedAt!: Date | null;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
albumThumbnailAssetId!: string;
@Column({ type: 'boolean', default: true })
isActivityEnabled!: boolean;
@Column({ default: AssetOrder.DESC })
order!: AssetOrder;
@ColumnIndex('IDX_albums_update_id')
@UpdateIdColumn()
updateId?: string;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { Permission } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
@@ -8,22 +9,19 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('api_keys')
@UpdatedAtTrigger('api_keys_updated_at')
export class APIKeyTable {
@PrimaryGeneratedColumn()
id!: string;
@Column()
name!: string;
@Column()
key!: string;
@Column({ array: true, type: 'character varying' })
permissions!: Permission[];
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@CreateDateColumn()
createdAt!: Date;
@@ -31,10 +29,13 @@ export class APIKeyTable {
@UpdateDateColumn()
updatedAt!: Date;
@PrimaryGeneratedColumn()
id!: string;
@Column({ array: true, type: 'character varying' })
permissions!: Permission[];
@ColumnIndex({ name: 'IDX_api_keys_update_id' })
@UpdateIdColumn()
updateId?: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
}

View File

@@ -1,8 +1,9 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
@Table('assets_audit')
export class AssetAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
@PrimaryGeneratedUuidV7Column()
id!: string;
@ColumnIndex('IDX_assets_audit_asset_id')

View File

@@ -1,4 +1,5 @@
import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@@ -7,8 +8,11 @@ import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColu
@Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] })
@Index({ columns: ['personId', 'assetId'] })
export class AssetFaceTable {
@PrimaryGeneratedColumn()
id!: string;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId!: string;
@ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
personId!: string | null;
@Column({ default: 0, type: 'integer' })
imageWidth!: number;
@@ -28,15 +32,12 @@ export class AssetFaceTable {
@Column({ default: 0, type: 'integer' })
boundingBoxY2!: number;
@Column({ default: SourceType.MACHINE_LEARNING, enumName: 'sourcetype', enum: SourceType })
@PrimaryGeneratedColumn()
id!: string;
@Column({ default: SourceType.MACHINE_LEARNING, enum: asset_face_source_type })
sourceType!: SourceType;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId!: string;
@ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
personId!: string | null;
@DeleteDateColumn()
deletedAt!: Date | null;
}

View File

@@ -1,5 +1,6 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetFileType } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
Column,
ColumnIndex,
@@ -9,18 +10,18 @@ import {
Table,
Unique,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] })
@Table('asset_files')
@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] })
@UpdatedAtTrigger('asset_files_updated_at')
export class AssetFileTable {
@PrimaryGeneratedColumn()
id!: string;
@ColumnIndex('IDX_asset_files_assetId')
@ForeignKeyColumn(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId?: AssetEntity;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId?: string;
@CreateDateColumn()
createdAt!: Date;
@@ -28,13 +29,13 @@ export class AssetFileTable {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_asset_files_update_id')
@UpdateIdColumn()
updateId?: string;
@Column()
type!: AssetFileType;
@Column()
path!: string;
@ColumnIndex('IDX_asset_files_update_id')
@UpdateIdColumn()
updateId?: string;
}

View File

@@ -1,9 +1,13 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum';
import { assets_status_enum } from 'src/schema/enums';
import { assets_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
ColumnIndex,
CreateDateColumn,
@@ -13,10 +17,17 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('assets')
@UpdatedAtTrigger('assets_updated_at')
@AfterDeleteTrigger({
name: 'assets_delete_audit',
scope: 'statement',
function: assets_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
// Checksums must be unique per user and library
@Index({
name: ASSET_CHECKSUM_CONSTRAINT,
@@ -30,7 +41,11 @@ import {
unique: true,
where: '("libraryId" IS NOT NULL)',
})
@Index({ name: 'idx_local_date_time', expression: `(("localDateTime" AT TIME ZONE 'UTC'::text))::date` })
@Index({
name: 'idx_local_date_time',
expression: `(("localDateTime" at time zone 'UTC')::date)`,
synchronize: false,
})
@Index({
name: 'idx_local_date_time_month',
expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`,
@@ -38,9 +53,10 @@ import {
@Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] })
@Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] })
@Index({
name: 'idx_originalFileName_trigram',
name: 'idx_originalfilename_trigram',
using: 'gin',
expression: 'f_unaccent(("originalFileName")::text)',
expression: 'f_unaccent("originalFileName") gin_trgm_ops',
synchronize: false,
})
// For all assets, each originalpath must be unique per user and library
export class AssetTable {
@@ -53,75 +69,50 @@ export class AssetTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
libraryId?: string | null;
@Column()
deviceId!: string;
@Column()
type!: AssetType;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@Column()
originalPath!: string;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'character varying', nullable: true, default: '' })
encodedVideoPath!: string | null;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_assets_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt!: Date | null;
@ColumnIndex('idx_asset_file_created_at')
@Column({ type: 'timestamp with time zone', default: null })
fileCreatedAt!: Date;
@Column({ type: 'timestamp with time zone', default: null })
localDateTime!: Date;
@Column({ type: 'timestamp with time zone', default: null })
fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'boolean', default: false })
isExternal!: boolean;
@Column({ type: 'boolean', default: false })
isOffline!: boolean;
@Column({ type: 'character varying', nullable: true, default: '' })
encodedVideoPath!: string | null;
@Column({ type: 'bytea' })
@ColumnIndex()
checksum!: Buffer; // sha1 checksum
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'boolean', default: true })
isVisible!: boolean;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null;
@UpdateDateColumn()
updatedAt!: Date;
@CreateDateColumn()
createdAt!: Date;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column()
@ColumnIndex()
originalFileName!: string;
@@ -129,10 +120,35 @@ export class AssetTable {
@Column({ nullable: true })
sidecarPath!: string | null;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'boolean', default: false })
isOffline!: boolean;
@ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
libraryId?: string | null;
@Column({ type: 'boolean', default: false })
isExternal!: boolean;
@DeleteDateColumn()
deletedAt!: Date | null;
@Column({ type: 'timestamp with time zone', default: null })
localDateTime!: Date;
@ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
stackId?: string | null;
@ColumnIndex('IDX_assets_duplicateId')
@Column({ type: 'uuid', nullable: true })
duplicateId!: string | null;
@Column({ enum: assets_status_enum, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@ColumnIndex('IDX_assets_update_id')
@UpdateIdColumn()
updateId?: string;
}

View File

@@ -4,7 +4,7 @@ import { Column, CreateDateColumn, Index, PrimaryColumn, Table } from 'src/sql-t
@Table('audit')
@Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] })
export class AuditTable {
@PrimaryColumn({ type: 'integer', default: 'increment', synchronize: false })
@PrimaryColumn({ type: 'serial', synchronize: false })
id!: number;
@Column()

View File

@@ -1,21 +1,18 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn, UpdateIdColumn } from 'src/sql-tools';
import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
@Table('exif')
@UpdatedAtTrigger('asset_exif_updated_at')
export class ExifTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
assetId!: string;
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
updatedAt?: Date;
@Column({ type: 'character varying', nullable: true })
make!: string | null;
@ColumnIndex('IDX_asset_exif_update_id')
@UpdateIdColumn()
updateId?: string;
/* General info */
@Column({ type: 'text', default: '' })
description!: string; // or caption
@Column({ type: 'character varying', nullable: true })
model!: string | null;
@Column({ type: 'integer', nullable: true })
exifImageWidth!: number | null;
@@ -35,43 +32,6 @@ export class ExifTable {
@Column({ type: 'timestamp with time zone', nullable: true })
modifyDate!: Date | null;
@Column({ type: 'character varying', nullable: true })
timeZone!: string | null;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@Column({ type: 'character varying', nullable: true })
projectionType!: string | null;
@ColumnIndex('exif_city')
@Column({ type: 'character varying', nullable: true })
city!: string | null;
@ColumnIndex('IDX_live_photo_cid')
@Column({ type: 'character varying', nullable: true })
livePhotoCID!: string | null;
@ColumnIndex('IDX_auto_stack_id')
@Column({ type: 'character varying', nullable: true })
autoStackId!: string | null;
@Column({ type: 'character varying', nullable: true })
state!: string | null;
@Column({ type: 'character varying', nullable: true })
country!: string | null;
/* Image info */
@Column({ type: 'character varying', nullable: true })
make!: string | null;
@Column({ type: 'character varying', nullable: true })
model!: string | null;
@Column({ type: 'character varying', nullable: true })
lensModel!: string | null;
@@ -84,9 +44,41 @@ export class ExifTable {
@Column({ type: 'integer', nullable: true })
iso!: number | null;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@ColumnIndex('exif_city')
@Column({ type: 'character varying', nullable: true })
city!: string | null;
@Column({ type: 'character varying', nullable: true })
state!: string | null;
@Column({ type: 'character varying', nullable: true })
country!: string | null;
@Column({ type: 'text', default: '' })
description!: string; // or caption
@Column({ type: 'double precision', nullable: true })
fps?: number | null;
@Column({ type: 'character varying', nullable: true })
exposureTime!: string | null;
@ColumnIndex('IDX_live_photo_cid')
@Column({ type: 'character varying', nullable: true })
livePhotoCID!: string | null;
@Column({ type: 'character varying', nullable: true })
timeZone!: string | null;
@Column({ type: 'character varying', nullable: true })
projectionType!: string | null;
@Column({ type: 'character varying', nullable: true })
profileDescription!: string | null;
@@ -96,10 +88,17 @@ export class ExifTable {
@Column({ type: 'integer', nullable: true })
bitsPerSample!: number | null;
@ColumnIndex('IDX_auto_stack_id')
@Column({ type: 'character varying', nullable: true })
autoStackId!: string | null;
@Column({ type: 'integer', nullable: true })
rating!: number | null;
/* Video info */
@Column({ type: 'double precision', nullable: true })
fps?: number | null;
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
updatedAt?: Date;
@ColumnIndex('IDX_asset_exif_update_id')
@UpdateIdColumn()
updateId?: string;
}

View File

@@ -1,7 +1,14 @@
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' })
@Index({
name: 'face_index',
using: 'hnsw',
expression: `embedding vector_cosine_ops`,
with: 'ef_construction = 300, m = 16',
synchronize: false,
})
export class FaceSearchTable {
@ForeignKeyColumn(() => AssetFaceTable, {
onDelete: 'CASCADE',
@@ -10,7 +17,6 @@ export class FaceSearchTable {
})
faceId!: string;
@ColumnIndex({ name: 'face_index', synchronize: false })
@Column({ type: 'vector', array: true, length: 512, synchronize: false })
@Column({ type: 'vector', length: 512, synchronize: false })
embedding!: string;
}

View File

@@ -1,10 +1,35 @@
import { Column, Index, PrimaryColumn, Table } from 'src/sql-tools';
@Index({ name: 'idx_geodata_places_alternate_names', expression: 'f_unaccent("alternateNames") gin_trgm_ops' })
@Index({ name: 'idx_geodata_places_admin1_name', expression: 'f_unaccent("admin1Name") gin_trgm_ops' })
@Index({ name: 'idx_geodata_places_admin2_name', expression: 'f_unaccent("admin2Name") gin_trgm_ops' })
@Index({ name: 'idx_geodata_places_name', expression: 'f_unaccent("name") gin_trgm_ops' })
@Index({ name: 'idx_geodata_places_gist_earthcoord', expression: 'll_to_earth_public(latitude, longitude)' })
@Table({ name: 'geodata_places' })
@Index({
name: 'idx_geodata_places_alternate_names',
using: 'gin',
expression: 'f_unaccent("alternateNames") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_admin1_name',
using: 'gin',
expression: 'f_unaccent("admin1Name") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_admin2_name',
using: 'gin',
expression: 'f_unaccent("admin2Name") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_name',
using: 'gin',
expression: 'f_unaccent("name") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_gist_earthcoord',
expression: 'll_to_earth_public(latitude, longitude)',
synchronize: false,
})
@Table({ name: 'idx_geodata_places', synchronize: false })
export class GeodataPlacesTable {
@PrimaryColumn({ type: 'integer' })
@@ -28,41 +53,8 @@ export class GeodataPlacesTable {
@Column({ type: 'character varying', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'character varying', nullable: true })
admin1Name!: string;
@Column({ type: 'character varying', nullable: true })
admin2Name!: string;
@Column({ type: 'character varying', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
}
@Table({ name: 'geodata_places_tmp', synchronize: false })
export class GeodataPlacesTempEntity {
@PrimaryColumn({ type: 'integer' })
id!: number;
@Column({ type: 'character varying', length: 200 })
name!: string;
@Column({ type: 'double precision' })
longitude!: number;
@Column({ type: 'double precision' })
latitude!: number;
@Column({ type: 'character', length: 2 })
countryCode!: string;
@Column({ type: 'character varying', length: 20, nullable: true })
admin1Code!: string;
@Column({ type: 'character varying', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'character varying', nullable: true })
admin1Name!: string;
@@ -72,7 +64,4 @@ export class GeodataPlacesTempEntity {
@Column({ type: 'character varying', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
}

View File

@@ -1,73 +1,35 @@
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { APIKeyTable } from 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { ExifTable } from 'src/schema/tables/exif.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table';
import {
NaturalEarthCountriesTable,
NaturalEarthCountriesTempTable,
} from 'src/schema/tables/natural-earth-countries.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { SmartSearchTable } from 'src/schema/tables/smart-search.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table';
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { UserAuditTable } from 'src/schema/tables/user-audit.table';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
export const tables = [
ActivityTable,
AlbumAssetTable,
AlbumUserTable,
AlbumTable,
APIKeyTable,
AssetAuditTable,
AssetFaceTable,
AssetJobStatusTable,
AssetTable,
AuditTable,
ExifTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MoveTable,
NaturalEarthCountriesTable,
NaturalEarthCountriesTempTable,
PartnerAuditTable,
PartnerTable,
PersonTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
SmartSearchTable,
StackTable,
SessionSyncCheckpointTable,
SystemMetadataTable,
TagAssetTable,
UserAuditTable,
UserMetadataTable,
UserTable,
VersionHistoryTable,
];
import 'src/schema/tables/activity.table';
import 'src/schema/tables/album-asset.table';
import 'src/schema/tables/album-user.table';
import 'src/schema/tables/album.table';
import 'src/schema/tables/api-key.table';
import 'src/schema/tables/asset-audit.table';
import 'src/schema/tables/asset-face.table';
import 'src/schema/tables/asset-files.table';
import 'src/schema/tables/asset-job-status.table';
import 'src/schema/tables/asset.table';
import 'src/schema/tables/audit.table';
import 'src/schema/tables/exif.table';
import 'src/schema/tables/face-search.table';
import 'src/schema/tables/geodata-places.table';
import 'src/schema/tables/library.table';
import 'src/schema/tables/memory.table';
import 'src/schema/tables/memory_asset.table';
import 'src/schema/tables/move.table';
import 'src/schema/tables/natural-earth-countries.table';
import 'src/schema/tables/partner-audit.table';
import 'src/schema/tables/partner.table';
import 'src/schema/tables/person.table';
import 'src/schema/tables/session.table';
import 'src/schema/tables/shared-link-asset.table';
import 'src/schema/tables/shared-link.table';
import 'src/schema/tables/smart-search.table';
import 'src/schema/tables/stack.table';
import 'src/schema/tables/sync-checkpoint.table';
import 'src/schema/tables/system-metadata.table';
import 'src/schema/tables/tag-asset.table';
import 'src/schema/tables/tag-closure.table';
import 'src/schema/tables/user-audit.table';
import 'src/schema/tables/user-metadata.table';
import 'src/schema/tables/user.table';
import 'src/schema/tables/version-history.table';

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
@@ -8,10 +9,10 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('libraries')
@UpdatedAtTrigger('libraries_updated_at')
export class LibraryTable {
@PrimaryGeneratedColumn()
id!: string;
@@ -34,13 +35,13 @@ export class LibraryTable {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_libraries_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
refreshedAt!: Date | null;
@ColumnIndex('IDX_libraries_update_id')
@UpdateIdColumn()
updateId?: string;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { MemoryType } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
@@ -9,11 +10,11 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { MemoryData } from 'src/types';
@Table('memories')
@UpdatedAtTrigger('memories_updated_at')
export class MemoryTable<T extends MemoryType = MemoryType> {
@PrimaryGeneratedColumn()
id!: string;
@@ -24,10 +25,6 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_memories_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt?: Date;
@@ -48,13 +45,17 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
@Column({ type: 'timestamp with time zone' })
memoryAt!: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamp with time zone', nullable: true })
seenAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
showAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
hideAt?: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamp with time zone', nullable: true })
seenAt?: Date;
@ColumnIndex('IDX_memories_update_id')
@UpdateIdColumn()
updateId?: string;
}

View File

@@ -4,11 +4,11 @@ import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('memories_assets_assets')
export class MemoryAssetTable {
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@ColumnIndex()
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string;
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
}

View File

@@ -1,26 +1,8 @@
import { Column, PrimaryColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { Column, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table({ name: 'naturalearth_countries', synchronize: false })
@Table({ name: 'naturalearth_countries' })
export class NaturalEarthCountriesTable {
@PrimaryColumn({ type: 'serial' })
id!: number;
@Column({ type: 'character varying', length: 50 })
admin!: string;
@Column({ type: 'character varying', length: 3 })
admin_a3!: string;
@Column({ type: 'character varying', length: 50 })
type!: string;
@Column({ type: 'polygon' })
coordinates!: string;
}
@Table({ name: 'naturalearth_countries_tmp', synchronize: false })
export class NaturalEarthCountriesTempTable {
@PrimaryGeneratedColumn()
@PrimaryGeneratedColumn({ strategy: 'identity' })
id!: number;
@Column({ type: 'character varying', length: 50 })

View File

@@ -1,8 +1,9 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
@Table('partners_audit')
export class PartnerAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
@PrimaryGeneratedUuidV7Column()
id!: string;
@ColumnIndex('IDX_partners_audit_shared_by_id')

View File

@@ -1,15 +1,25 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { partners_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('partners')
@UpdatedAtTrigger('partners_updated_at')
@AfterDeleteTrigger({
name: 'partners_delete_audit',
scope: 'statement',
function: partners_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class PartnerTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true })
sharedById!: string;
@@ -23,10 +33,10 @@ export class PartnerTable {
@UpdateDateColumn()
updatedAt!: Date;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
@ColumnIndex('IDX_partners_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
@@ -9,10 +10,10 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('person')
@UpdatedAtTrigger('person_updated_at')
@Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable {
@PrimaryGeneratedColumn('uuid')
@@ -24,31 +25,31 @@ export class PersonTable {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_person_update_id')
@UpdateIdColumn()
updateId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: string;
@Column({ type: 'date', nullable: true })
birthDate!: Date | string | null;
@Column({ default: '' })
thumbnailPath!: string;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@Column({ type: 'boolean', default: false })
isHidden!: boolean;
@Column({ type: 'date', nullable: true })
birthDate!: Date | string | null;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ type: 'character varying', nullable: true, default: null })
color?: string | null;
@ColumnIndex('IDX_person_update_id')
@UpdateIdColumn()
updateId!: string;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
@@ -7,10 +8,10 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' })
@UpdatedAtTrigger('sessions_updated_at')
export class SessionTable {
@PrimaryGeneratedColumn()
id!: string;
@@ -19,22 +20,22 @@ export class SessionTable {
@Column()
token!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_sessions_update_id')
@UpdateIdColumn()
updateId!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@Column({ default: '' })
deviceType!: string;
@Column({ default: '' })
deviceOS!: string;
@ColumnIndex('IDX_sessions_update_id')
@UpdateIdColumn()
updateId!: string;
}

View File

@@ -20,16 +20,9 @@ export class SharedLinkTable {
@Column({ type: 'character varying', nullable: true })
description!: string | null;
@Column({ type: 'character varying', nullable: true })
password!: string | null;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ColumnIndex('IDX_sharedlink_albumId')
@ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@ColumnIndex('IDX_sharedlink_key')
@Column({ type: 'bytea' })
key!: Buffer; // use to access the inidividual asset
@@ -46,9 +39,16 @@ export class SharedLinkTable {
@Column({ type: 'boolean', default: false })
allowUpload!: boolean;
@ColumnIndex('IDX_sharedlink_albumId')
@ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@Column({ type: 'boolean', default: true })
allowDownload!: boolean;
@Column({ type: 'boolean', default: true })
showExif!: boolean;
@Column({ type: 'character varying', nullable: true })
password!: string | null;
}

View File

@@ -1,7 +1,14 @@
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' })
@Index({
name: 'clip_index',
using: 'hnsw',
expression: `embedding vector_cosine_ops`,
with: `ef_construction = 300, m = 16`,
synchronize: false,
})
export class SmartSearchTable {
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
@@ -10,7 +17,6 @@ export class SmartSearchTable {
})
assetId!: string;
@ColumnIndex({ name: 'clip_index', synchronize: false })
@Column({ type: 'vector', array: true, length: 512, synchronize: false })
@Column({ type: 'vector', length: 512, storage: 'external', synchronize: false })
embedding!: string;
}

View File

@@ -7,10 +7,10 @@ export class StackTable {
@PrimaryGeneratedColumn()
id!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
ownerId!: string;
//TODO: Add constraint to ensure primary asset exists in the assets array
@ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true })
primaryAssetId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
ownerId!: string;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SyncEntityType } from 'src/enum';
import { SessionTable } from 'src/schema/tables/session.table';
import {
@@ -8,10 +9,10 @@ import {
PrimaryColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('session_sync_checkpoints')
@UpdatedAtTrigger('session_sync_checkpoints_updated_at')
export class SessionSyncCheckpointTable {
@ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true })
sessionId!: string;
@@ -25,10 +26,10 @@ export class SessionSyncCheckpointTable {
@UpdateDateColumn()
updatedAt!: Date;
@Column()
ack!: string;
@ColumnIndex('IDX_session_sync_checkpoints_update_id')
@UpdateIdColumn()
updateId!: string;
@Column()
ack!: string;
}

View File

@@ -1,15 +1,13 @@
import { TagTable } from 'src/schema/tables/tag.table';
import { ColumnIndex, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools';
import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('tags_closure')
export class TagClosureTable {
@PrimaryColumn()
@ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
@ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
id_ancestor!: string;
@PrimaryColumn()
@ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
@ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
id_descendant!: string;
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
@@ -8,15 +9,18 @@ import {
Table,
Unique,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Table('tags')
@UpdatedAtTrigger('tags_updated_at')
@Unique({ columns: ['userId', 'value'] })
export class TagTable {
@PrimaryGeneratedColumn()
id!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@Column()
value!: string;
@@ -26,16 +30,13 @@ export class TagTable {
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_tags_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ type: 'character varying', nullable: true, default: null })
color!: string | null;
@ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' })
parentId?: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@ColumnIndex('IDX_tags_update_id')
@UpdateIdColumn()
updateId!: string;
}

View File

@@ -1,14 +1,15 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
@Table('users_audit')
export class UserAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
id!: string;
@Column({ type: 'uuid' })
userId!: string;
@ColumnIndex('IDX_users_audit_deleted_at')
@CreateDateColumn({ default: () => 'clock_timestamp()' })
deletedAt!: Date;
@PrimaryGeneratedUuidV7Column()
id!: string;
}

View File

@@ -1,6 +1,9 @@
import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserStatus } from 'src/enum';
import { users_delete_audit } from 'src/schema/functions';
import {
AfterDeleteTrigger,
Column,
ColumnIndex,
CreateDateColumn,
@@ -9,7 +12,6 @@ import {
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>;
@@ -17,50 +19,51 @@ type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('users')
@UpdatedAtTrigger('users_updated_at')
@AfterDeleteTrigger({
name: 'users_delete_audit',
scope: 'statement',
function: users_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
@Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] })
export class UserTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ unique: true })
email!: string;
@Column({ default: '' })
name!: Generated<string>;
password!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: false })
isAdmin!: Generated<boolean>;
@Column({ unique: true })
email!: string;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ default: '' })
oauthId!: Generated<string>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null;
@Column({ default: '' })
password!: Generated<string>;
@Column({ default: '' })
oauthId!: Generated<string>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
name!: Generated<string>;
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null;
@@ -68,6 +71,13 @@ export class UserTable {
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: Generated<ColumnType<number>>;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
profileChangedAt!: Generated<Timestamp>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
}

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Activity } from 'src/database';
import {
ActivityCreateDto,
ActivityDto,
@@ -13,7 +14,6 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ActivityItem } from 'src/types';
@Injectable()
export class ActivityService extends BaseService {
@@ -43,7 +43,7 @@ export class ActivityService extends BaseService {
albumId: dto.albumId,
};
let activity: ActivityItem | undefined;
let activity: Activity | undefined;
let duplicate = false;
if (dto.type === ReactionType.LIKE) {

View File

@@ -140,7 +140,7 @@ export class AlbumService extends BaseService {
order: dto.order,
});
return mapAlbumWithoutAssets(updatedAlbum);
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
}
async delete(auth: AuthDto, id: string): Promise<void> {

View File

@@ -1,9 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ApiKey } from 'src/database';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ApiKeyItem } from 'src/types';
import { isGranted } from 'src/utils/access';
@Injectable()
@@ -58,7 +58,7 @@ export class ApiKeyService extends BaseService {
return keys.map((key) => this.map(key));
}
private map(entity: ApiKeyItem): APIKeyResponseDto {
private map(entity: ApiKey): APIKeyResponseDto {
return {
id: entity.id,
name: entity.name,

View File

@@ -1,10 +1,10 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service';
import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -97,17 +97,19 @@ describe('AuthService', () => {
});
it('should successfully log the user in', async () => {
mocks.user.getByEmail.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid);
const user = { ...factory.user(), password: 'immich_password' } as UserEntity;
const session = factory.session();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id',
userEmail: 'immich@test.com',
name: 'immich_name',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: user.shouldChangePassword,
});
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
@@ -256,8 +258,14 @@ describe('AuthService', () => {
});
it('should validate using authorization header', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any);
const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect(
sut.authenticate({
@@ -266,8 +274,8 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}),
).resolves.toEqual({
user: userStub.user1,
session: sessionStub.valid,
user: sessionWithToken.user,
session: { id: session.id },
});
});
});
@@ -371,7 +379,14 @@ describe('AuthService', () => {
});
it('should return an auth dto', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any);
const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect(
sut.authenticate({
@@ -380,13 +395,20 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}),
).resolves.toEqual({
user: userStub.user1,
session: sessionStub.valid,
user: sessionWithToken.user,
session: { id: session.id },
});
});
it('should throw if admin route and not an admin', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any);
const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect(
sut.authenticate({
@@ -398,8 +420,15 @@ describe('AuthService', () => {
});
it('should update when access time exceeds an hour', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any);
mocks.session.update.mockResolvedValue(sessionStub.valid);
const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
mocks.session.update.mockResolvedValue(session);
await expect(
sut.authenticate({
@@ -408,7 +437,8 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}),
).resolves.toBeDefined();
expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
expect(mocks.session.update).toHaveBeenCalled();
});
});
@@ -506,7 +536,7 @@ describe('AuthService', () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(userStub.user1);
mocks.user.update.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid);
mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse,
@@ -535,7 +565,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid);
mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse,
@@ -550,7 +580,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid);
mocks.session.create.mockResolvedValue(factory.session());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
@@ -572,7 +602,7 @@ describe('AuthService', () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid);
mocks.session.create.mockResolvedValue(factory.session());
await sut.callback({ url }, loginDetails);

View File

@@ -338,7 +338,9 @@ export class AuthService extends BaseService {
return {
user: session.user,
session,
session: {
id: session.id,
},
};
}

Some files were not shown because too many files have changed in this diff Show More