Compare commits

...

22 Commits

Author SHA1 Message Date
Jason Rasmussen
f7285191bd WIP 2024-04-22 16:51:24 -04:00
Aaron Berndsen
c9a079201a docs: update "move all data" instructions in FAQ (#8976)
* Update FAQ.mdx

chore(docs): update "move all data" FAQ instructions.

* Apply suggestions from code review

fix: (apply suggestions) use sql-compliant comments

Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com>

* fix: Update FAQ.mdx

---------

Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com>
2024-04-22 12:53:55 +00:00
Alex
be4a783845 fix(web): wrong month on timeline scrollbar cursor (#8996)
* fix(web): wrong month on timeline scrollbar cursor

* revert unnesessary change
2024-04-22 06:22:59 -05:00
Mert
c30cd3b378 chore: test more formats in e2e (#9001) 2024-04-22 01:35:27 -04:00
TruongSinh Tran-Nguyen
0d3cc28f45 feat(web): support 360 video (equirectangular) (#8762)
* [web]: support 360 video

* lint

* lint

* fix typing

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-04-21 19:14:54 +00:00
Conner Hnatiuk
f004487be0 fix(web): trash page now auto refreshes (#8978)
* fix(web): the trash page now auto refreshes when restore all or empty trash is clicked. Also shows number of assets affected.

* formatting
2024-04-21 14:07:17 -05:00
clementdelestre
21231d53a5 feat(mobile): add i18n in multiselect-grid and update translation (en and fr) (#8993)
* add i18n in multiselect grid (en-fr)

* add FR translations from (haptic feedback)

* revert settings
2024-04-21 13:26:19 -05:00
Daniel Dietzler
a99862120d feat: mobile label for renovate pull requests (#8991)
mobile lable for renovate pull requests
2024-04-21 13:11:03 -05:00
shenlong
776023b149 dep(mobile): upgrade gradle (#8409)
* dep(mobile): upgrade gradle

* chore(deps): update kotlin & guava

* build: change java version and flutter test version

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2024-04-20 23:07:32 -05:00
martin
7d4187962a feat(web): new look option for slideshow (#8924)
feat: new look option for slideshow
2024-04-20 23:06:49 -05:00
Jason Rasmussen
a93534fc3c refactor(server): session interface types (#8977) 2024-04-20 23:45:55 -04:00
Alex
cef84f6ced chore(mobile): override appbundle on PlayStore before getting released (#8960) 2024-04-20 19:56:03 -05:00
Alex The Bot
a2180a467d Version v1.102.3 2024-04-20 20:17:39 +00:00
Jason Rasmussen
1e3dceea4d fix(server): session refresh (#8974) 2024-04-20 15:15:25 -05:00
Mert
fd4514711f feat(server): enable AV1 encoding for NVENC (#8959)
allow av1 for nvenc
2024-04-20 14:52:50 -04:00
Alex
2dd7c13b88 Revert "feat(android) Check server is reachable before starting background backup (#8594)" (#8958)
This reverts commit 71b6d8b569.
2024-04-20 12:15:26 -05:00
Alex
40931b5668 chore: post release tasks 2024-04-20 11:15:41 -05:00
Alex The Bot
25549b87c9 Version v1.102.2 2024-04-20 15:55:32 +00:00
Alex
7ec62f12b5 Revert "fix(mobile): random logout (#8739)" (#8954)
This reverts commit 97c099e26d.
2024-04-20 10:53:52 -05:00
Jaryl Chng
caf76f0713 feat(server): enable AV1 encoding for QSV (#8942) 2024-04-20 10:36:00 -04:00
martin
6778653825 fix(web): keep focus when searching people (#8950)
fix: keep focus when searching people

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-04-20 14:18:31 +00:00
Alex
c858b43717 chore: post release tasks 2024-04-20 09:12:11 -05:00
112 changed files with 1470 additions and 535 deletions

View File

@@ -37,15 +37,15 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: "zulu"
java-version: "11.0.21+9"
cache: "gradle"
distribution: 'zulu'
java-version: '17'
cache: 'gradle'
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.19.3"
channel: 'stable'
flutter-version: '3.19.3'
cache: true
- name: Create the Keystore

View File

@@ -208,7 +208,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.16.9'
flutter-version: '3.19.3'
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1

2
cli/package-lock.json generated
View File

@@ -47,7 +47,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.102.1",
"version": "1.102.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -117,7 +117,7 @@ For example, say you have existing transcodes with the policy "Videos higher tha
No. Our design principle is that the original assets should always be untouched.
### How can I move all data (photos, persons, albums) from one user to another?
### How can I move all data (photos, persons, albums, libraries) from one user to another?
This is not officially supported but can be accomplished with some database updates. You can do this on the command line (in the PostgreSQL container using the `psql` command), or you can add, for example, an [Adminer](https://www.adminer.org/) container to the `docker-compose.yml` file so that you can use a web interface.
@@ -128,18 +128,21 @@ This is not officially supported but can be accomplished with some database upda
2. Find the ID of both the 'source' and the 'destination' user (it's the id column in the `users` table)
3. Three tables need to be updated:
3. Four tables need to be updated:
```sql
// Reassign albums
-- reassign albums
UPDATE albums SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
// Reassign people
-- reassign people
UPDATE person SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
// reassign assets
-- reassign assets
UPDATE assets SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>'
AND CHECKSUM NOT IN (SELECT CHECKSUM FROM assets WHERE "ownerId" = '<destinationId>');
-- reassign external libraries
UPDATE libraries SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
```
4. There might be left-over assets in the 'source' user's library if they are skipped by the last query because of duplicate checksums. These are probably duplicates anyway, and can probably be removed.

6
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.102.1",
"version": "1.102.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.102.1",
"version": "1.102.3",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
@@ -81,7 +81,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.102.1",
"version": "1.102.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.102.1",
"version": "1.102.3",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -572,6 +572,22 @@ describe('/asset', () => {
}
const tests = [
{
input: 'formats/avif/8bit-sRGB.avif',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '8bit-sRGB.avif',
resized: true,
exifInfo: {
description: '',
exifImageHeight: 1080,
exifImageWidth: 1617,
fileSizeInByte: 862_424,
latitude: null,
longitude: null,
},
},
},
{
input: 'formats/jpg/el_torcal_rocks.jpg',
expected: {
@@ -596,6 +612,22 @@ describe('/asset', () => {
},
},
},
{
input: 'formats/jxl/8bit-sRGB.jxl',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '8bit-sRGB.jxl',
resized: true,
exifInfo: {
description: '',
exifImageHeight: 1080,
exifImageWidth: 1440,
fileSizeInByte: 1_780_777,
latitude: null,
longitude: null,
},
},
},
{
input: 'formats/heic/IMG_2682.heic',
expected: {
@@ -681,6 +713,80 @@ describe('/asset', () => {
},
},
},
{
input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '4_3.rw2',
resized: true,
fileCreatedAt: '2018-05-10T08:42:37.842Z',
exifInfo: {
make: 'Panasonic',
model: 'DMC-GH4',
exifImageHeight: 3456,
exifImageWidth: 4608,
exposureTime: '1/100',
fNumber: 3.2,
focalLength: 35,
iso: 400,
fileSizeInByte: 19_587_072,
dateTimeOriginal: '2018-05-10T08:42:37.842Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '12bit-compressed-(3_2).arw',
resized: true,
fileCreatedAt: '2016-09-27T10:51:44.000Z',
exifInfo: {
make: 'SONY',
model: 'ILCE-6300',
exifImageHeight: 4024,
exifImageWidth: 6048,
exposureTime: '1/320',
fNumber: 8,
focalLength: 97,
iso: 100,
lensModel: 'E PZ 18-105mm F4 G OSS',
fileSizeInByte: 25_001_984,
dateTimeOriginal: '2016-09-27T10:51:44.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw',
expected: {
type: AssetTypeEnum.Image,
originalFileName: '14bit-uncompressed-(3_2).arw',
resized: true,
fileCreatedAt: '2016-01-08T15:08:01.000Z',
exifInfo: {
make: 'SONY',
model: 'ILCE-7M2',
exifImageHeight: 4024,
exifImageWidth: 6048,
exposureTime: '1.3',
fNumber: 22,
focalLength: 25,
iso: 100,
lensModel: 'E 25mm F2',
fileSizeInByte: 49_512_448,
dateTimeOriginal: '2016-01-08T15:08:01.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
];
for (const { input, expected } of tests) {

View File

@@ -8,7 +8,7 @@ import {
} from '@immich/sdk';
import { cpSync, existsSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
import request from 'supertest';
@@ -18,7 +18,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) =>
scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) });
describe('/library', () => {
describe.skip('/library', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let library: LibraryResponseDto;
@@ -28,7 +28,7 @@ describe('/library', () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.resetAdminConfig(admin.accessToken);
user = await utils.userSetup(admin.accessToken, userDto.user1);
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External });
websocket = await utils.connectWebsocket(admin.accessToken);
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`);

View File

@@ -135,7 +135,7 @@ describe('/user', () => {
expect(body).toEqual(errorDto.unauthorized);
});
for (const key of Object.keys(createUserDto.user1)) {
for (const key of ['email', 'password', 'name', 'permissionPreset']) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.post(`/user`)
@@ -146,6 +146,17 @@ describe('/user', () => {
});
}
it(`should require permissions when using the custom preset `, async () => {
const { status, body } = await request(app)
.post(`/user`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, permissionPreset: 'custom' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('each value in permissions must be one of the following')]),
);
});
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(app)
.post(`/user`)
@@ -154,6 +165,7 @@ describe('/user', () => {
email: 'user5@immich.cloud',
password: 'password123',
name: 'Immich',
permissionPreset: 'user',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
@@ -172,6 +184,7 @@ describe('/user', () => {
password: 'Password123',
name: 'No Memories',
memoriesEnabled: false,
permissionPreset: 'user',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({

View File

@@ -1,4 +1,4 @@
import { UserAvatarColor } from '@immich/sdk';
import { PermissionPreset, UserAvatarColor } from '@immich/sdk';
export const uuidDto = {
invalid: 'invalid-uuid',
@@ -26,33 +26,39 @@ export const createUserDto = {
email: `${key}@immich.cloud`,
name: `Generated User ${key}`,
password: `password-${key}`,
permissionPreset: PermissionPreset.User,
};
},
user1: {
email: 'user1@immich.cloud',
name: 'User 1',
password: 'password1',
permissionPreset: PermissionPreset.User,
},
user2: {
email: 'user2@immich.cloud',
name: 'User 2',
password: 'password12',
permissionPreset: PermissionPreset.User,
},
user3: {
email: 'user3@immich.cloud',
name: 'User 3',
permissionPreset: PermissionPreset.User,
password: 'password123',
},
user4: {
email: 'user4@immich.cloud',
name: 'User 4',
password: 'password123',
permissionPreset: PermissionPreset.User,
},
userQuota: {
email: 'user-quota@immich.cloud',
name: 'User Quota',
password: 'password-quota',
quotaSizeInBytes: 512,
permissionPreset: PermissionPreset.User,
},
};

View File

@@ -77,6 +77,91 @@ export const signupResponseDto = {
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
permissions: [
'activity.create',
'activity.read',
'activity.update',
'activity.delete',
'album.create',
'album.read',
'album.update',
'album.delete',
'asset.create',
'asset.read',
'asset.update',
'asset.delete',
'apiKey.create',
'apiKey.read',
'apiKey.update',
'apiKey.delete',
'authDevice.create',
'authDevice.read',
'authDevice.update',
'authDevice.delete',
'face.create',
'face.read',
'face.update',
'face.delete',
'library.create',
'library.read',
'library.update',
'library.delete',
'memory.create',
'memory.read',
'memory.update',
'memory.delete',
'memory.addAsset',
'memory.removeAsset',
'partner.create',
'partner.read',
'partner.update',
'partner.delete',
'person.create',
'person.read',
'person.update',
'person.delete',
'report.create',
'report.read',
'report.update',
'report.delete',
'sharedLink.create',
'sharedLink.read',
'sharedLink.update',
'sharedLink.delete',
'systemConfig.read',
'systemConfig.update',
'systemConfig.delete',
'stack.create',
'stack.read',
'stack.update',
'stack.delete',
'tag.create',
'tag.read',
'tag.update',
'tag.delete',
'user.create',
'user.read',
'user.update',
'user.delete',
'auth.changePassword',
'auth.oauth',
'album.addAsset',
'album.removeAsset',
'album.addUser',
'album.removeUser',
'asset.viewThumb',
'asset.viewPreview',
'asset.viewOriginal',
'asset.upload',
'asset.download',
'job.read',
'job.run',
'map.read',
'user.readSimple',
'user.changePassword',
'server.read',
'server.setup',
],
},
};

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.102.1"
version = "1.102.3"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -1,14 +1,14 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "kotlin-kapt"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
localPropertiesFile.withInputStream { localProperties.load(it) }
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
@@ -21,18 +21,12 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
}
android {
compileSdkVersion 34
@@ -50,7 +44,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich"
minSdkVersion 26
targetSdkVersion 33
@@ -88,9 +81,16 @@ flutter {
}
dependencies {
def kotlin_version = '1.9.23'
def kotlin_coroutines_version = '1.8.0'
def work_version = '2.9.0'
def concurrent_version = '1.1.0'
def guava_version = '33.1.0-android'
def glide_version = '4.16.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime:$work_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
implementation "com.google.guava:guava:$guava_version"
implementation "com.github.bumptech.glide:glide:$glide_version"

View File

@@ -52,7 +52,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.putString(BackupWorker.SHARED_PREF_SERVER_URL, args.get(3) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true)

View File

@@ -11,8 +11,8 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ForegroundInfo
@@ -30,16 +30,6 @@ import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.URL
import java.util.concurrent.TimeUnit
/**
@@ -52,6 +42,7 @@ import java.util.concurrent.TimeUnit
*/
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
private val resolvableFuture = ResolvableFuture.create<Result>()
private var engine: FlutterEngine? = null
private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -61,80 +52,35 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private var notificationDetailBuilder: NotificationCompat.Builder? = null
private var fgFuture: ListenableFuture<Void>? = null
private val job = Job()
private lateinit var completer: CallbackToFutureAdapter.Completer<Result>
private val resolvableFuture = CallbackToFutureAdapter.getFuture { completer ->
this.completer = completer
null
}
init {
resolvableFuture.addListener(
Runnable {
if (resolvableFuture.isCancelled) {
job.cancel()
}
},
taskExecutor.serialTaskExecutor
)
}
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext
val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
prefs.getString(SHARED_PREF_SERVER_URL, null)
?.takeIf { it.isNotEmpty() }
?.let { serverUrl -> doCoroutineWork(serverUrl) }
?: doWork()
return resolvableFuture
}
/**
* This function is used to check if server URL is reachable before starting the backup work.
* Check must be done in a background to avoid blocking the main thread.
*/
private fun doCoroutineWork(serverUrl : String) {
CoroutineScope(Dispatchers.Default + job).launch {
val isReachable = isUrlReachableHttp(serverUrl)
withContext(Dispatchers.Main) {
if (isReachable) {
doWork()
} else {
// Fail when the URL is not reachable
completer.set(Result.failure())
}
}
if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a Notification channel if necessary
createChannel()
}
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showInfo(getInfoBuilder(title, indeterminate=true).build())
}
engine = FlutterEngine(ctx)
private fun doWork() {
Log.d(TAG, "doWork")
val ctx = applicationContext
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a Notification channel if necessary
createChannel()
}
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showInfo(getInfoBuilder(title, indeterminate=true).build())
}
engine = FlutterEngine(ctx)
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
return resolvableFuture
}
/**
@@ -193,7 +139,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
engine = null
if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
this.completer.set(result)
resolvableFuture.set(result)
}
waitOnSetForegroundAsync()
}
@@ -324,7 +270,6 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
const val SHARED_PREF_SERVER_URL = "serverUrl"
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
@@ -401,7 +346,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
@@ -415,26 +360,3 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
}
private const val TAG = "BackupWorker"
/**
* Check if the given URL is reachable via HTTP
*/
suspend fun isUrlReachableHttp(url: String, timeoutMillis: Long = 5000L): Boolean {
return withTimeoutOrNull(timeoutMillis) {
var httpURLConnection: HttpURLConnection? = null
try {
httpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "HEAD"
connectTimeout = timeoutMillis.toInt()
readTimeout = timeoutMillis.toInt()
}
httpURLConnection.connect()
httpURLConnection.responseCode == HttpURLConnection.HTTP_OK
} catch (e: Exception) {
Log.e(TAG, "Failed to reach server URL: $e")
false
} finally {
httpURLConnection?.disconnect()
}
} == true
}

View File

@@ -1,21 +1,3 @@
buildscript {
ext.kotlin_version = '1.8.22'
ext.kotlin_coroutines_version = '1.7.1'
ext.work_version = '2.9.0'
ext.concurrent_version = '1.1.0'
ext.guava_version = '33.0.0-android'
ext.glide_version = '4.14.2'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
@@ -34,3 +16,7 @@ subprojects {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
tasks.named('wrapper') {
distributionType = Wrapper.DistributionType.ALL
}

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 133,
"android.injected.version.name" => "1.102.1",
"android.injected.version.code" => 136,
"android.injected.version.name" => "1.102.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000261">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="73.93743">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="32.48099">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="34.73691">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.236974">
</testcase>

View File

@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
distributionSha256Sum=fe696c020f241a5f69c30f763c5a7f38eec54b490db19cd2b0962dda420d7d12

View File

@@ -1,11 +1,26 @@
include ':app'
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.4.2" apply false
id "org.jetbrains.kotlin.android" version "1.9.23" apply false
id "org.jetbrains.kotlin.kapt" version "1.9.23" apply false
}
include ":app"

View File

@@ -296,6 +296,7 @@
"motion_photos_page_title": "Motion Photos",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
"multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping",
"no_assets_to_show" : "No assets to show",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Settings",

View File

@@ -296,6 +296,7 @@
"motion_photos_page_title": "Photos avec mouvement",
"multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.",
"multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.",
"no_assets_to_show" : "Aucun élément à afficher",
"notification_permission_dialog_cancel": "Annuler",
"notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.",
"notification_permission_dialog_settings": "Paramètres",
@@ -509,5 +510,7 @@
"version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89",
"viewer_remove_from_stack": "Retirer de la pile",
"viewer_stack_use_as_main_asset": "Utiliser comme élément principal",
"viewer_unstack": "Désempiler"
"viewer_unstack": "Désempiler",
"haptic_feedback_title": "Retour haptique",
"haptic_feedback_switch": "Activer le retour haptique"
}

View File

@@ -383,7 +383,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 150;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -525,7 +525,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 150;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -553,7 +553,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 150;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.102.0</string>
<string>1.102.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>148</string>
<string>150</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.102.1"
version_number: "1.102.3"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000399">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000231">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.247535">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.155919">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="8.325258">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.252784">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.180002">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.210502">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="199.335284">
<testcase classname="fastlane.lanes" name="4: build_app" time="175.813647">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="90.564254">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="73.512517">
</testcase>

View File

@@ -20,7 +20,6 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -69,10 +68,8 @@ class BackgroundService {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod(
'enable',
[callback.toRawHandle(), title, immediate, getServerUrl()],
);
final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok;
} catch (error) {
return false;

View File

@@ -63,7 +63,7 @@ class MultiselectGrid extends HookConsumerWidget {
const Center(child: ImmichLoadingIndicator());
Widget buildEmptyIndicator() =>
emptyIndicator ?? const Center(child: Text("No assets to show"));
emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr());
@override
Widget build(BuildContext context, WidgetRef ref) {

View File

@@ -25,7 +25,6 @@ class SplashScreenPage extends HookConsumerWidget {
void performLoggingIn() async {
bool isSuccess = false;
bool deviceIsOffline = false;
if (accessToken != null && serverUrl != null) {
try {
// Resolve API server endpoint from user provided serverUrl
@@ -51,11 +50,15 @@ class SplashScreenPage extends HookConsumerWidget {
offlineLogin: deviceIsOffline,
);
} catch (error, stackTrace) {
ref.read(authenticationProvider.notifier).logout();
log.severe(
'Cannot set success login info',
error,
stackTrace,
);
context.pushRoute(const LoginRoute());
}
}
@@ -73,11 +76,6 @@ class SplashScreenPage extends HookConsumerWidget {
}
context.replaceRoute(const TabControllerRoute());
} else {
log.severe(
'Unable to login through offline or online methods - logging out completely',
);
ref.read(authenticationProvider.notifier).logout();
// User was unable to login through either offline or online methods
context.replaceRoute(const LoginRoute());
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.102.1
- API version: 1.102.3
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -1804,5 +1804,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.2.0 <4.0.0"
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.16.0"

View File

@@ -2,10 +2,10 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.102.1+133
version: 1.102.3+136
environment:
sdk: '>=3.0.0 <4.0.0'
sdk: '>=3.3.0 <4.0.0'
dependencies:
flutter:

View File

@@ -6428,15 +6428,6 @@
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
},
{
"bearer": []
},
@@ -6564,15 +6555,6 @@
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
},
{
"bearer": []
},
@@ -7078,7 +7060,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.102.1",
"version": "1.102.3",
"contact": {}
},
"tags": [],
@@ -7975,6 +7957,103 @@
],
"type": "object"
},
"AuthorizationPermission": {
"enum": [
"activity.create",
"activity.read",
"activity.update",
"activity.delete",
"album.create",
"album.read",
"album.update",
"album.delete",
"asset.create",
"asset.read",
"asset.update",
"asset.delete",
"apiKey.create",
"apiKey.read",
"apiKey.update",
"apiKey.delete",
"authDevice.create",
"authDevice.read",
"authDevice.update",
"authDevice.delete",
"face.create",
"face.read",
"face.update",
"face.delete",
"library.create",
"library.read",
"library.update",
"library.delete",
"memory.create",
"memory.read",
"memory.update",
"memory.delete",
"memory.addAsset",
"memory.removeAsset",
"partner.create",
"partner.read",
"partner.update",
"partner.delete",
"person.create",
"person.read",
"person.update",
"person.delete",
"report.create",
"report.read",
"report.update",
"report.delete",
"session.create",
"session.read",
"session.update",
"session.delete",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
"sharedLink.delete",
"systemConfig.create",
"systemConfig.read",
"systemConfig.update",
"systemConfig.delete",
"systemMetadata.create",
"systemMetadata.read",
"systemMetadata.update",
"systemMetadata.delete",
"stack.create",
"stack.read",
"stack.update",
"stack.delete",
"tag.create",
"tag.read",
"tag.update",
"tag.delete",
"user.create",
"user.read",
"user.update",
"user.delete",
"auth.changePassword",
"auth.oauth",
"album.addAsset",
"album.removeAsset",
"album.addUser",
"album.removeUser",
"asset.viewThumb",
"asset.viewPreview",
"asset.viewOriginal",
"asset.upload",
"asset.download",
"job.read",
"job.run",
"map.read",
"user.readSimple",
"user.changePassword",
"server.read",
"server.setup"
],
"type": "string"
},
"BulkIdResponseDto": {
"properties": {
"error": {
@@ -8284,6 +8363,15 @@
"password": {
"type": "string"
},
"permissionPreset": {
"$ref": "#/components/schemas/PermissionPreset"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/AuthorizationPermission"
},
"type": "array"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
@@ -8300,7 +8388,8 @@
"required": [
"email",
"name",
"password"
"password",
"permissionPreset"
],
"type": "object"
},
@@ -9364,6 +9453,106 @@
"oauthId": {
"type": "string"
},
"permissions": {
"items": {
"enum": [
"activity.create",
"activity.read",
"activity.update",
"activity.delete",
"album.create",
"album.read",
"album.update",
"album.delete",
"asset.create",
"asset.read",
"asset.update",
"asset.delete",
"apiKey.create",
"apiKey.read",
"apiKey.update",
"apiKey.delete",
"authDevice.create",
"authDevice.read",
"authDevice.update",
"authDevice.delete",
"face.create",
"face.read",
"face.update",
"face.delete",
"library.create",
"library.read",
"library.update",
"library.delete",
"memory.create",
"memory.read",
"memory.update",
"memory.delete",
"memory.addAsset",
"memory.removeAsset",
"partner.create",
"partner.read",
"partner.update",
"partner.delete",
"person.create",
"person.read",
"person.update",
"person.delete",
"report.create",
"report.read",
"report.update",
"report.delete",
"session.create",
"session.read",
"session.update",
"session.delete",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
"sharedLink.delete",
"systemConfig.create",
"systemConfig.read",
"systemConfig.update",
"systemConfig.delete",
"systemMetadata.create",
"systemMetadata.read",
"systemMetadata.update",
"systemMetadata.delete",
"stack.create",
"stack.read",
"stack.update",
"stack.delete",
"tag.create",
"tag.read",
"tag.update",
"tag.delete",
"user.create",
"user.read",
"user.update",
"user.delete",
"auth.changePassword",
"auth.oauth",
"album.addAsset",
"album.removeAsset",
"album.addUser",
"album.removeUser",
"asset.viewThumb",
"asset.viewPreview",
"asset.viewOriginal",
"asset.upload",
"asset.download",
"job.read",
"job.run",
"map.read",
"user.readSimple",
"user.changePassword",
"server.read",
"server.setup"
],
"type": "string"
},
"type": "array"
},
"profileImagePath": {
"type": "string"
},
@@ -9497,6 +9686,14 @@
],
"type": "object"
},
"PermissionPreset": {
"enum": [
"user",
"admin",
"custom"
],
"type": "string"
},
"PersonCreateDto": {
"properties": {
"birthDate": {
@@ -11252,6 +11449,15 @@
"password": {
"type": "string"
},
"permissionPreset": {
"$ref": "#/components/schemas/PermissionPreset"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/AuthorizationPermission"
},
"type": "array"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
@@ -11377,6 +11583,106 @@
"oauthId": {
"type": "string"
},
"permissions": {
"items": {
"enum": [
"activity.create",
"activity.read",
"activity.update",
"activity.delete",
"album.create",
"album.read",
"album.update",
"album.delete",
"asset.create",
"asset.read",
"asset.update",
"asset.delete",
"apiKey.create",
"apiKey.read",
"apiKey.update",
"apiKey.delete",
"authDevice.create",
"authDevice.read",
"authDevice.update",
"authDevice.delete",
"face.create",
"face.read",
"face.update",
"face.delete",
"library.create",
"library.read",
"library.update",
"library.delete",
"memory.create",
"memory.read",
"memory.update",
"memory.delete",
"memory.addAsset",
"memory.removeAsset",
"partner.create",
"partner.read",
"partner.update",
"partner.delete",
"person.create",
"person.read",
"person.update",
"person.delete",
"report.create",
"report.read",
"report.update",
"report.delete",
"session.create",
"session.read",
"session.update",
"session.delete",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
"sharedLink.delete",
"systemConfig.create",
"systemConfig.read",
"systemConfig.update",
"systemConfig.delete",
"systemMetadata.create",
"systemMetadata.read",
"systemMetadata.update",
"systemMetadata.delete",
"stack.create",
"stack.read",
"stack.update",
"stack.delete",
"tag.create",
"tag.read",
"tag.update",
"tag.delete",
"user.create",
"user.read",
"user.update",
"user.delete",
"auth.changePassword",
"auth.oauth",
"album.addAsset",
"album.removeAsset",
"album.addUser",
"album.removeUser",
"asset.viewThumb",
"asset.viewPreview",
"asset.viewOriginal",
"asset.upload",
"asset.download",
"job.read",
"job.run",
"map.read",
"user.readSimple",
"user.changePassword",
"server.read",
"server.setup"
],
"type": "string"
},
"type": "array"
},
"profileImagePath": {
"type": "string"
},

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.102.1",
"version": "1.102.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.102.1",
"version": "1.102.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.102.1",
"version": "1.102.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.102.1
* 1.102.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -71,6 +71,7 @@ export type UserResponseDto = {
memoriesEnabled?: boolean;
name: string;
oauthId: string;
permissions?: ("activity.create" | "activity.read" | "activity.update" | "activity.delete" | "album.create" | "album.read" | "album.update" | "album.delete" | "apiKey.create" | "apiKey.read" | "apiKey.update" | "apiKey.delete" | "asset.create" | "asset.read" | "asset.update" | "asset.delete" | "authDevice.create" | "authDevice.read" | "authDevice.update" | "authDevice.delete" | "face.create" | "face.read" | "face.update" | "face.delete" | "memory.create" | "memory.read" | "memory.update" | "memory.delete" | "partner.create" | "partner.read" | "partner.update" | "partner.delete" | "person.create" | "person.read" | "person.update" | "person.delete" | "sharedLink.create" | "sharedLink.read" | "sharedLink.update" | "sharedLink.delete" | "systemConfig.read" | "systemConfig.update" | "systemConfig.delete" | "stack.create" | "stack.read" | "stack.update" | "stack.delete" | "tag.create" | "tag.read" | "tag.update" | "tag.delete" | "user.create" | "user.readSimple" | "user.read" | "user.update" | "user.delete")[];
profileImagePath: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
@@ -513,6 +514,7 @@ export type PartnerResponseDto = {
memoriesEnabled?: boolean;
name: string;
oauthId: string;
permissions?: ("activity.create" | "activity.read" | "activity.update" | "activity.delete" | "album.create" | "album.read" | "album.update" | "album.delete" | "apiKey.create" | "apiKey.read" | "apiKey.update" | "apiKey.delete" | "asset.create" | "asset.read" | "asset.update" | "asset.delete" | "authDevice.create" | "authDevice.read" | "authDevice.update" | "authDevice.delete" | "face.create" | "face.read" | "face.update" | "face.delete" | "memory.create" | "memory.read" | "memory.update" | "memory.delete" | "partner.create" | "partner.read" | "partner.update" | "partner.delete" | "person.create" | "person.read" | "person.update" | "person.delete" | "sharedLink.create" | "sharedLink.read" | "sharedLink.update" | "sharedLink.delete" | "systemConfig.read" | "systemConfig.update" | "systemConfig.delete" | "stack.create" | "stack.read" | "stack.update" | "stack.delete" | "tag.create" | "tag.read" | "tag.update" | "tag.delete" | "user.create" | "user.readSimple" | "user.read" | "user.update" | "user.delete")[];
profileImagePath: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
@@ -1021,6 +1023,8 @@ export type CreateUserDto = {
memoriesEnabled?: boolean;
name: string;
password: string;
permissionPreset: PermissionPreset;
permissions?: AuthorizationPermission[];
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
@@ -1033,6 +1037,8 @@ export type UpdateUserDto = {
memoriesEnabled?: boolean;
name?: string;
password?: string;
permissionPreset?: PermissionPreset;
permissions?: AuthorizationPermission[];
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string;
@@ -3103,3 +3109,66 @@ export enum TimeBucketSize {
Day = "DAY",
Month = "MONTH"
}
export enum PermissionPreset {
User = "user",
Admin = "admin",
Custom = "custom"
}
export enum AuthorizationPermission {
ActivityCreate = "activity.create",
ActivityRead = "activity.read",
ActivityUpdate = "activity.update",
ActivityDelete = "activity.delete",
AlbumCreate = "album.create",
AlbumRead = "album.read",
AlbumUpdate = "album.update",
AlbumDelete = "album.delete",
ApiKeyCreate = "apiKey.create",
ApiKeyRead = "apiKey.read",
ApiKeyUpdate = "apiKey.update",
ApiKeyDelete = "apiKey.delete",
AssetCreate = "asset.create",
AssetRead = "asset.read",
AssetUpdate = "asset.update",
AssetDelete = "asset.delete",
AuthDeviceCreate = "authDevice.create",
AuthDeviceRead = "authDevice.read",
AuthDeviceUpdate = "authDevice.update",
AuthDeviceDelete = "authDevice.delete",
FaceCreate = "face.create",
FaceRead = "face.read",
FaceUpdate = "face.update",
FaceDelete = "face.delete",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",
MemoryDelete = "memory.delete",
PartnerCreate = "partner.create",
PartnerRead = "partner.read",
PartnerUpdate = "partner.update",
PartnerDelete = "partner.delete",
PersonCreate = "person.create",
PersonRead = "person.read",
PersonUpdate = "person.update",
PersonDelete = "person.delete",
SharedLinkCreate = "sharedLink.create",
SharedLinkRead = "sharedLink.read",
SharedLinkUpdate = "sharedLink.update",
SharedLinkDelete = "sharedLink.delete",
SystemConfigRead = "systemConfig.read",
SystemConfigUpdate = "systemConfig.update",
SystemConfigDelete = "systemConfig.delete",
StackCreate = "stack.create",
StackRead = "stack.read",
StackUpdate = "stack.update",
StackDelete = "stack.delete",
TagCreate = "tag.create",
TagRead = "tag.read",
TagUpdate = "tag.update",
TagDelete = "tag.delete",
UserCreate = "user.create",
UserReadSimple = "user.readSimple",
UserRead = "user.read",
UserUpdate = "user.update",
UserDelete = "user.delete"
}

View File

@@ -27,7 +27,8 @@
"matchFileNames": ["mobile/**"],
"groupName": "mobile",
"matchUpdateTypes": ["minor", "patch"],
"schedule": "on tuesday"
"schedule": "on tuesday",
"addLabels": ["📱mobile"]
},
{
"groupName": "exiftool",

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.102.1",
"version": "1.102.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.102.1",
"version": "1.102.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.102.1",
"version": "1.102.3",
"description": "",
"author": "",
"private": true,

View File

@@ -8,28 +8,30 @@ import {
ActivitySearchDto,
ActivityStatisticsResponseDto,
} from 'src/dtos/activity.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ActivityService } from 'src/services/activity.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Activity')
@Controller('activity')
@Authenticated()
export class ActivityController {
constructor(private service: ActivityService) {}
@Get()
@Authenticated(Permission.ACTIVITY_READ)
getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
return this.service.getAll(auth, dto);
}
@Get('statistics')
@Authenticated(Permission.ACTIVITY_READ)
getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@Post()
@Authenticated(Permission.ACTIVITY_CREATE)
async createActivity(
@Auth() auth: AuthDto,
@Body() dto: ActivityCreateDto,
@@ -43,6 +45,7 @@ export class ActivityController {
}
@Delete(':id')
@Authenticated(Permission.ACTIVITY_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);

View File

@@ -10,34 +10,36 @@ import {
UpdateAlbumDto,
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
@ApiTags('Album')
@Controller('album')
@Authenticated()
export class AlbumController {
constructor(private service: AlbumService) {}
@Get('count')
@Authenticated(Permission.ALBUM_READ)
getAlbumCount(@Auth() auth: AuthDto): Promise<AlbumCountResponseDto> {
return this.service.getCount(auth);
}
@Get()
@Authenticated(Permission.ALBUM_READ)
getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(auth, query);
}
@Post()
@Authenticated(Permission.ALBUM_CREATE)
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {
return this.service.create(auth, dto);
}
@SharedLinkRoute()
@Get(':id')
@Authenticated(Permission.ALBUM_READ, { sharedLink: true })
getAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -47,6 +49,7 @@ export class AlbumController {
}
@Patch(':id')
@Authenticated(Permission.ALBUM_UPDATE)
updateAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -56,12 +59,13 @@ export class AlbumController {
}
@Delete(':id')
@Authenticated(Permission.ALBUM_DELETE)
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}
@SharedLinkRoute()
@Put(':id/assets')
@Authenticated(Permission.ALBUM_ADD_ASSET, { sharedLink: true })
addAssetsToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -71,6 +75,7 @@ export class AlbumController {
}
@Delete(':id/assets')
@Authenticated(Permission.ALBUM_REMOVE_ASSET)
removeAssetFromAlbum(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -80,6 +85,7 @@ export class AlbumController {
}
@Put(':id/users')
@Authenticated(Permission.ALBUM_ADD_USER)
addUsersToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -89,6 +95,7 @@ export class AlbumController {
}
@Delete(':id/user/:userId')
@Authenticated(Permission.ALBUM_REMOVE_USER, { bypassParamId: 'userId' })
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -1,33 +1,36 @@
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { APIKeyService } from 'src/services/api-key.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('API Key')
@Controller('api-key')
@Authenticated()
export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
@Authenticated(Permission.API_KEY_CREATE)
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated(Permission.API_KEY_READ)
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated(Permission.API_KEY_READ)
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated(Permission.API_KEY_UPDATE)
updateApiKey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -37,6 +40,7 @@ export class APIKeyController {
}
@Delete(':id')
@Authenticated(Permission.API_KEY_DELETE)
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -1,6 +1,5 @@
import { Controller, Get, Header } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { PublicRoute } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@Controller()
@@ -18,7 +17,6 @@ export class AppController {
}
@ApiExcludeEndpoint()
@PublicRoute()
@Get('custom.css')
@Header('Content-Type', 'text/css')
getCustomCss() {

View File

@@ -31,8 +31,8 @@ import {
GetAssetThumbnailDto,
ServeFileDto,
} from 'src/dtos/asset-v1.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/middleware/auth.guard';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from 'src/middleware/file-upload.interceptor';
import { AssetServiceV1 } from 'src/services/asset-v1.service';
import { sendFile } from 'src/utils/file';
@@ -46,12 +46,11 @@ interface UploadFiles {
@ApiTags('Asset')
@Controller(Route.ASSET)
@Authenticated()
export class AssetControllerV1 {
constructor(private service: AssetServiceV1) {}
@SharedLinkRoute()
@Post('upload')
@Authenticated(Permission.ASSET_UPLOAD, { sharedLink: true })
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({
@@ -85,8 +84,8 @@ export class AssetControllerV1 {
return responseDto;
}
@SharedLinkRoute()
@Get('/file/:id')
@Authenticated(Permission.ASSET_VIEW_ORIGINAL, { sharedLink: true })
@FileResponse()
async serveFile(
@Res() res: Response,
@@ -98,8 +97,8 @@ export class AssetControllerV1 {
await sendFile(res, next, () => this.service.serveFile(auth, id, dto));
}
@SharedLinkRoute()
@Get('/thumbnail/:id')
@Authenticated(Permission.ASSET_VIEW_THUMB, { sharedLink: true })
@FileResponse()
async getAssetThumbnail(
@Res() res: Response,
@@ -112,16 +111,19 @@ export class AssetControllerV1 {
}
@Get('/curated-objects')
@Authenticated(Permission.ASSET_READ)
getCuratedObjects(@Auth() auth: AuthDto): Promise<CuratedObjectsResponseDto[]> {
return this.service.getCuratedObject(auth);
}
@Get('/curated-locations')
@Authenticated(Permission.ASSET_READ)
getCuratedLocations(@Auth() auth: AuthDto): Promise<CuratedLocationsResponseDto[]> {
return this.service.getCuratedLocation(auth);
}
@Get('/search-terms')
@Authenticated(Permission.ASSET_READ)
getAssetSearchTerms(@Auth() auth: AuthDto): Promise<string[]> {
return this.service.getAssetSearchTerm(auth);
}
@@ -130,6 +132,7 @@ export class AssetControllerV1 {
* Get all AssetEntity belong to the user
*/
@Get('/')
@Authenticated(Permission.ASSET_READ)
@ApiHeader({
name: 'if-none-match',
description: 'ETag of data already cached on the client',
@@ -144,6 +147,7 @@ export class AssetControllerV1 {
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('/exist')
@Authenticated(Permission.ASSET_READ)
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@Auth() auth: AuthDto,
@@ -156,6 +160,7 @@ export class AssetControllerV1 {
* Checks if assets exist by checksums
*/
@Post('/bulk-upload-check')
@Authenticated(Permission.ASSET_READ)
@HttpCode(HttpStatus.OK)
checkBulkUpload(
@Auth() auth: AuthDto,

View File

@@ -11,10 +11,10 @@ import {
RandomAssetsDto,
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, MetadataSearchDto } from 'src/dtos/search.dto';
import { UpdateStackParentDto } from 'src/dtos/stack.dto';
import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { Route } from 'src/middleware/file-upload.interceptor';
import { AssetService } from 'src/services/asset.service';
import { SearchService } from 'src/services/search.service';
@@ -22,11 +22,11 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Asset')
@Controller('assets')
@Authenticated()
export class AssetsController {
constructor(private searchService: SearchService) {}
@Get()
@Authenticated(Permission.ASSET_READ)
@ApiOperation({ deprecated: true })
async searchAssets(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise<AssetResponseDto[]> {
const {
@@ -38,21 +38,23 @@ export class AssetsController {
@ApiTags('Asset')
@Controller(Route.ASSET)
@Authenticated()
export class AssetController {
constructor(private service: AssetService) {}
@Get('map-marker')
@Authenticated(Permission.ASSET_READ)
getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(auth, options);
}
@Get('memory-lane')
@Authenticated(Permission.MEMORY_READ)
getMemoryLane(@Auth() auth: AuthDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
return this.service.getMemoryLane(auth, dto);
}
@Get('random')
@Authenticated(Permission.ASSET_READ)
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(auth, dto.count ?? 1);
}
@@ -61,46 +63,54 @@ export class AssetController {
* Get all asset of a device that are in the database, ID only.
*/
@Get('/device/:deviceId')
@Authenticated(Permission.ASSET_READ)
getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) {
return this.service.getUserAssetsByDeviceId(auth, deviceId);
}
@Get('statistics')
@Authenticated(Permission.ASSET_READ)
getAssetStatistics(@Auth() auth: AuthDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@Post('jobs')
// TODO
@Authenticated(Permission.ASSET_READ)
@HttpCode(HttpStatus.NO_CONTENT)
runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise<void> {
return this.service.run(auth, dto);
}
@Put()
@Authenticated(Permission.ASSET_UPDATE)
@HttpCode(HttpStatus.NO_CONTENT)
updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated(Permission.ASSET_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkDeleteDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Put('stack/parent')
@Authenticated(Permission.STACK_UPDATE)
@HttpCode(HttpStatus.OK)
updateStackParent(@Auth() auth: AuthDto, @Body() dto: UpdateStackParentDto): Promise<void> {
return this.service.updateStackParent(auth, dto);
}
@SharedLinkRoute()
@Get(':id')
@Authenticated(Permission.ASSET_READ, { sharedLink: true })
getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.service.get(auth, id) as Promise<AssetResponseDto>;
}
@Put(':id')
@Authenticated(Permission.ASSET_UPDATE)
updateAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -1,17 +1,17 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuditDeletesDto, AuditDeletesResponseDto } from 'src/dtos/audit.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuditService } from 'src/services/audit.service';
@ApiTags('Audit')
@Controller('audit')
@Authenticated()
export class AuditController {
constructor(private service: AuditService) {}
@Get('deletes')
@Authenticated(Permission.ASSET_READ)
getAuditDeletes(@Auth() auth: AuthDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
return this.service.getDeletes(auth, dto);
}

View File

@@ -9,21 +9,20 @@ import {
LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto,
Permission,
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@ApiTags('Authentication')
@Controller('auth')
@Authenticated()
export class AuthController {
constructor(private service: AuthService) {}
@PublicRoute()
@Post('login')
async login(
@Body() loginCredential: LoginCredentialDto,
@@ -41,25 +40,27 @@ export class AuthController {
});
}
@PublicRoute()
@Post('admin-sign-up')
signUpAdmin(@Body() dto: SignUpDto): Promise<UserResponseDto> {
return this.service.adminSignUp(dto);
}
@Post('validateToken')
@Authenticated(Permission.AUTH_DEVICE_READ)
@HttpCode(HttpStatus.OK)
validateAccessToken(): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Post('change-password')
@Authenticated(Permission.AUTH_CHANGE_PASSWORD)
@HttpCode(HttpStatus.OK)
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.service.changePassword(auth, dto).then(mapUser);
}
@Post('logout')
@Authenticated(Permission.AUTH_DEVICE_DELETE)
@HttpCode(HttpStatus.OK)
async logout(
@Req() request: Request,

View File

@@ -2,35 +2,34 @@ import { Body, Controller, HttpCode, HttpStatus, Next, Param, Post, Res, Streama
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/middleware/auth.guard';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { DownloadService } from 'src/services/download.service';
import { asStreamableFile, sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Download')
@Controller('download')
@Authenticated()
export class DownloadController {
constructor(private service: DownloadService) {}
@SharedLinkRoute()
@Post('info')
@Authenticated(Permission.ASSET_READ, { sharedLink: true })
getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
return this.service.getDownloadInfo(auth, dto);
}
@SharedLinkRoute()
@Post('archive')
@Authenticated(Permission.ASSET_DOWNLOAD, { sharedLink: true })
@HttpCode(HttpStatus.OK)
@FileResponse()
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
}
@SharedLinkRoute()
@Post('asset/:id')
@Authenticated(Permission.ASSET_DOWNLOAD, { sharedLink: true })
@HttpCode(HttpStatus.OK)
@FileResponse()
async downloadFile(

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
@@ -8,16 +8,17 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Face')
@Controller('face')
@Authenticated()
export class FaceController {
constructor(private service: PersonService) {}
@Get()
@Authenticated(Permission.FACE_READ)
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
return this.service.getFacesById(auth, dto);
}
@Put(':id')
@Authenticated(Permission.FACE_UPDATE)
reassignFacesById(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -1,29 +1,29 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { FileChecksumDto, FileChecksumResponseDto, FileReportDto, FileReportFixDto } from 'src/dtos/audit.dto';
import { AdminRoute, Authenticated } from 'src/middleware/auth.guard';
import { Permission } from 'src/dtos/auth.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { AuditService } from 'src/services/audit.service';
@ApiTags('File Report')
@Controller('report')
@Authenticated()
export class ReportController {
constructor(private service: AuditService) {}
@AdminRoute()
@Get()
@Authenticated(Permission.REPORT_READ)
getAuditFiles(): Promise<FileReportDto> {
return this.service.getFileReport();
}
@AdminRoute()
@Post('/checksum')
@Authenticated(Permission.REPORT_READ)
getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> {
return this.service.getChecksums(dto);
}
@AdminRoute()
@Post('/fix')
@Authenticated(Permission.REPORT_UPDATE)
fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> {
return this.service.fixItems(dto.items);
}

View File

@@ -1,21 +1,23 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Permission } from 'src/dtos/auth.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service';
@ApiTags('Job')
@Controller('jobs')
@Authenticated({ admin: true })
export class JobController {
constructor(private service: JobService) {}
@Get()
@Authenticated(Permission.JOB_READ)
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.service.getAllJobsStatus();
}
@Put(':id')
@Authenticated(Permission.JOB_RUN)
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
return this.service.handleCommand(id, dto);
}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Permission } from 'src/dtos/auth.dto';
import {
CreateLibraryDto,
LibraryResponseDto,
@@ -10,38 +11,41 @@ import {
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { AdminRoute, Authenticated } from 'src/middleware/auth.guard';
import { Authenticated } from 'src/middleware/auth.guard';
import { LibraryService } from 'src/services/library.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Library')
@Controller('library')
@Authenticated()
@AdminRoute()
export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
@Authenticated(Permission.LIBRARY_READ)
getAllLibraries(@Query() dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
return this.service.getAll(dto);
}
@Post()
@Authenticated(Permission.LIBRARY_CREATE)
createLibrary(@Body() dto: CreateLibraryDto): Promise<LibraryResponseDto> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated(Permission.LIBRARY_UPDATE)
updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
return this.service.update(id, dto);
}
@Get(':id')
@Authenticated(Permission.LIBRARY_READ)
getLibrary(@Param() { id }: UUIDParamDto): Promise<LibraryResponseDto> {
return this.service.get(id);
}
@Post(':id/validate')
@Authenticated(Permission.LIBRARY_READ)
@HttpCode(200)
// TODO: change endpoint to validate current settings instead
validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
@@ -49,23 +53,27 @@ export class LibraryController {
}
@Delete(':id')
@Authenticated(Permission.LIBRARY_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@Get(':id/statistics')
@Authenticated(Permission.LIBRARY_READ)
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
return this.service.getStatistics(id);
}
@Post(':id/scan')
@Authenticated(Permission.LIBRARY_UPDATE)
@HttpCode(HttpStatus.NO_CONTENT)
scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
return this.service.queueScan(id, dto);
}
@Post(':id/removeOffline')
@Authenticated(Permission.LIBRARY_UPDATE)
@HttpCode(HttpStatus.NO_CONTENT)
removeOfflineFiles(@Param() { id }: UUIDParamDto) {
return this.service.queueRemoveOffline(id);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service';
@@ -9,26 +9,29 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Memory')
@Controller('memories')
@Authenticated()
export class MemoryController {
constructor(private service: MemoryService) {}
@Get()
@Authenticated(Permission.MEMORY_READ)
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth);
}
@Post()
@Authenticated(Permission.MEMORY_CREATE)
createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise<MemoryResponseDto> {
return this.service.create(auth, dto);
}
@Get(':id')
@Authenticated(Permission.MEMORY_READ)
getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MemoryResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated(Permission.MEMORY_UPDATE)
updateMemory(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -38,12 +41,14 @@ export class MemoryController {
}
@Delete(':id')
@Authenticated(Permission.MEMORY_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
@Authenticated(Permission.MEMORY_ADD_ASSET)
addMemoryAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -53,6 +58,7 @@ export class MemoryController {
}
@Delete(':id/assets')
@Authenticated(Permission.MEMORY_REMOVE_ASSET)
@HttpCode(HttpStatus.OK)
removeMemoryAssets(
@Auth() auth: AuthDto,

View File

@@ -9,19 +9,18 @@ import {
OAuthAuthorizeResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
Permission,
} from 'src/dtos/auth.dto';
import { UserResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags('OAuth')
@Controller('oauth')
@Authenticated()
export class OAuthController {
constructor(private service: AuthService) {}
@PublicRoute()
@Get('mobile-redirect')
@Redirect()
redirectOAuthToMobile(@Req() request: Request) {
@@ -31,13 +30,11 @@ export class OAuthController {
};
}
@PublicRoute()
@Post('authorize')
startOAuth(@Body() dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
return this.service.authorize(dto);
}
@PublicRoute()
@Post('callback')
async finishOAuth(
@Res({ passthrough: true }) res: Response,
@@ -56,11 +53,13 @@ export class OAuthController {
}
@Post('link')
@Authenticated(Permission.AUTH_OAUTH)
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> {
return this.service.link(auth, dto);
}
@Post('unlink')
@Authenticated(Permission.AUTH_OAUTH)
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserResponseDto> {
return this.service.unlink(auth);
}

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { PartnerResponseDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerDirection } from 'src/interfaces/partner.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@@ -9,11 +9,11 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Partner')
@Controller('partner')
@Authenticated()
export class PartnerController {
constructor(private service: PartnerService) {}
@Get()
@Authenticated(Permission.PARTNER_READ)
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
// TODO: remove 'direction' and convert to full query dto
getPartners(@Auth() auth: AuthDto, @Query('direction') direction: PartnerDirection): Promise<PartnerResponseDto[]> {
@@ -21,11 +21,13 @@ export class PartnerController {
}
@Post(':id')
@Authenticated(Permission.PARTNER_CREATE)
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, id);
}
@Put(':id')
@Authenticated(Permission.PARTNER_UPDATE)
updatePartner(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -35,6 +37,7 @@ export class PartnerController {
}
@Delete(':id')
@Authenticated(Permission.PARTNER_DELETE)
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import {
AssetFaceUpdateDto,
MergePersonDto,
@@ -22,31 +22,35 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Person')
@Controller('person')
@Authenticated()
export class PersonController {
constructor(private service: PersonService) {}
@Get()
@Authenticated(Permission.PERSON_READ)
getAllPeople(@Auth() auth: AuthDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(auth, withHidden);
}
@Post()
@Authenticated(Permission.PERSON_CREATE)
createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.service.create(auth, dto);
}
@Put()
@Authenticated(Permission.PERSON_UPDATE)
updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updateAll(auth, dto);
}
@Get(':id')
@Authenticated(Permission.PERSON_READ)
getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated(Permission.PERSON_UPDATE)
updatePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -56,11 +60,13 @@ export class PersonController {
}
@Get(':id/statistics')
@Authenticated(Permission.PERSON_READ)
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
}
@Get(':id/thumbnail')
@Authenticated(Permission.PERSON_READ)
@FileResponse()
async getPersonThumbnail(
@Res() res: Response,
@@ -72,11 +78,13 @@ export class PersonController {
}
@Get(':id/assets')
@Authenticated(Permission.ASSET_READ)
getPersonAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
return this.service.getAssets(auth, id);
}
@Put(':id/reassign')
@Authenticated(Permission.PERSON_UPDATE)
reassignFaces(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -86,6 +94,7 @@ export class PersonController {
}
@Post(':id/merge')
@Authenticated(Permission.PERSON_UPDATE)
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
import {
MetadataSearchDto,
@@ -19,49 +19,56 @@ import { SearchService } from 'src/services/search.service';
@ApiTags('Search')
@Controller('search')
@Authenticated()
export class SearchController {
constructor(private service: SearchService) {}
@Get()
@Authenticated(Permission.ASSET_READ)
@ApiOperation({ deprecated: true })
search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
return this.service.search(auth, dto);
}
@Post('metadata')
@Authenticated(Permission.ASSET_READ)
@HttpCode(HttpStatus.OK)
searchMetadata(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto);
}
@Post('smart')
@Authenticated(Permission.ASSET_READ)
@HttpCode(HttpStatus.OK)
searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto);
}
@Get('explore')
@Authenticated(Permission.ASSET_READ)
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
return this.service.getExploreData(auth) as Promise<SearchExploreResponseDto[]>;
}
@Get('person')
@Authenticated(Permission.PERSON_READ)
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(auth, dto);
}
@Get('places')
@Authenticated(Permission.ASSET_READ)
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
return this.service.searchPlaces(dto);
}
@Get('cities')
@Authenticated(Permission.ASSET_READ)
getAssetsByCity(@Auth() auth: AuthDto): Promise<AssetResponseDto[]> {
return this.service.getAssetsByCity(auth);
}
@Get('suggestions')
@Authenticated(Permission.ASSET_READ)
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto);
}

View File

@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Permission } from 'src/dtos/auth.dto';
import {
ServerConfigDto,
ServerFeaturesDto,
@@ -10,57 +11,51 @@ import {
ServerThemeDto,
ServerVersionResponseDto,
} from 'src/dtos/server-info.dto';
import { AdminRoute, Authenticated, PublicRoute } from 'src/middleware/auth.guard';
import { Authenticated } from 'src/middleware/auth.guard';
import { ServerInfoService } from 'src/services/server-info.service';
@ApiTags('Server Info')
@Controller('server-info')
@Authenticated()
export class ServerInfoController {
constructor(private service: ServerInfoService) {}
@Get()
@Authenticated(Permission.SERVER_READ)
getServerInfo(): Promise<ServerInfoResponseDto> {
return this.service.getInfo();
}
@PublicRoute()
@Get('ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@PublicRoute()
@Get('version')
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();
}
@PublicRoute()
@Get('features')
getServerFeatures(): Promise<ServerFeaturesDto> {
return this.service.getFeatures();
}
@PublicRoute()
@Get('theme')
getTheme(): Promise<ServerThemeDto> {
return this.service.getTheme();
}
@PublicRoute()
@Get('config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getConfig();
}
@AdminRoute()
@Get('statistics')
@Authenticated(Permission.SERVER_READ)
getServerStatistics(): Promise<ServerStatsResponseDto> {
return this.service.getStatistics();
}
@PublicRoute()
@Get('media-types')
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();

View File

@@ -1,6 +1,6 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service';
@@ -8,22 +8,24 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Sessions')
@Controller('sessions')
@Authenticated()
export class SessionController {
constructor(private service: SessionService) {}
@Get()
@Authenticated(Permission.SESSION_READ)
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {
return this.service.getAll(auth);
}
@Delete()
@Authenticated(Permission.SESSION_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllSessions(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteAll(auth);
}
@Delete(':id')
@Authenticated(Permission.SESSION_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);

View File

@@ -3,14 +3,14 @@ import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto';
import { AuthDto, ImmichCookie, Permission } from 'src/dtos/auth.dto';
import {
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkPasswordDto,
SharedLinkResponseDto,
} from 'src/dtos/shared-link.dto';
import { Auth, Authenticated, GetLoginDetails, SharedLinkRoute } from 'src/middleware/auth.guard';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { respondWithCookie } from 'src/utils/response';
@@ -18,17 +18,17 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Shared Link')
@Controller('shared-link')
@Authenticated()
export class SharedLinkController {
constructor(private service: SharedLinkService) {}
@Get()
@Authenticated(Permission.SHARED_LINK_READ)
getAllSharedLinks(@Auth() auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.service.getAll(auth);
}
@SharedLinkRoute()
@Get('me')
@Authenticated(Permission.SHARED_LINK_READ, { sharedLink: true })
async getMySharedLink(
@Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto,
@@ -48,16 +48,19 @@ export class SharedLinkController {
}
@Get(':id')
@Authenticated(Permission.SHARED_LINK_READ)
getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<SharedLinkResponseDto> {
return this.service.get(auth, id);
}
@Post()
@Authenticated(Permission.SHARED_LINK_CREATE)
createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) {
return this.service.create(auth, dto);
}
@Patch(':id')
@Authenticated(Permission.SHARED_LINK_UPDATE)
updateSharedLink(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -67,12 +70,13 @@ export class SharedLinkController {
}
@Delete(':id')
@Authenticated(Permission.SHARED_LINK_DELETE)
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@SharedLinkRoute()
@Put(':id/assets')
@Authenticated(Permission.SHARED_LINK_UPDATE, { sharedLink: true })
addSharedLinkAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -81,8 +85,8 @@ export class SharedLinkController {
return this.service.addAssets(auth, id, dto);
}
@SharedLinkRoute()
@Delete(':id/assets')
@Authenticated(Permission.SHARED_LINK_DELETE, { sharedLink: true })
removeSharedLinkAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -1,23 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SyncService } from 'src/services/sync.service';
@ApiTags('Sync')
@Controller('sync')
@Authenticated()
export class SyncController {
constructor(private service: SyncService) {}
@Get('full-sync')
@Authenticated(Permission.ASSET_READ)
getAllForUserFullSync(@Auth() auth: AuthDto, @Query() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getAllAssetsForUserFullSync(auth, dto);
}
@Get('delta-sync')
@Authenticated(Permission.ASSET_READ)
getDeltaSync(@Auth() auth: AuthDto, @Query() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getChangesForDeltaSync(auth, dto);
}

View File

@@ -1,38 +1,41 @@
import { Body, Controller, Get, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Permission } from 'src/dtos/auth.dto';
import { MapThemeDto, SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AdminRoute, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@ApiTags('System Config')
@Controller('system-config')
@Authenticated({ admin: true })
export class SystemConfigController {
constructor(private service: SystemConfigService) {}
@Get()
@Authenticated(Permission.SYSTEM_CONFIG_READ)
getConfig(): Promise<SystemConfigDto> {
return this.service.getConfig();
}
@Get('defaults')
@Authenticated(Permission.SYSTEM_CONFIG_READ)
getConfigDefaults(): SystemConfigDto {
return this.service.getDefaults();
}
@Put()
@Authenticated(Permission.SYSTEM_CONFIG_UPDATE)
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.service.updateConfig(dto);
}
@Get('storage-template-options')
@Authenticated(Permission.SYSTEM_CONFIG_READ)
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.service.getStorageTemplateOptions();
}
@AdminRoute(false)
@SharedLinkRoute()
@Get('map/style.json')
@Authenticated(Permission.MAP_READ, { sharedLink: true })
getMapStyle(@Query() dto: MapThemeDto) {
return this.service.getMapStyle(dto.theme);
}

View File

@@ -1,27 +1,30 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Permission } from 'src/dtos/auth.dto';
import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@ApiTags('System Metadata')
@Controller('system-metadata')
@Authenticated({ admin: true })
export class SystemMetadataController {
constructor(private service: SystemMetadataService) {}
@Get('admin-onboarding')
@Authenticated(Permission.SYSTEM_METADATA_READ)
getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> {
return this.service.getAdminOnboarding();
}
@Post('admin-onboarding')
@Authenticated(Permission.SYSTEM_METADATA_UPDATE)
@HttpCode(HttpStatus.NO_CONTENT)
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
@Get('reverse-geocoding-state')
@Authenticated(Permission.SYSTEM_METADATA_READ)
getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
return this.service.getReverseGeocodingState();
}

View File

@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TagService } from 'src/services/tag.service';
@@ -11,41 +11,47 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('Tag')
@Controller('tag')
@Authenticated()
export class TagController {
constructor(private service: TagService) {}
@Post()
@Authenticated(Permission.TAG_CREATE)
createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated(Permission.TAG_READ)
getAllTags(@Auth() auth: AuthDto): Promise<TagResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated(Permission.TAG_READ)
getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
return this.service.getById(auth, id);
}
@Patch(':id')
@Authenticated(Permission.TAG_UPDATE)
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated(Permission.TAG_DELETE)
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Get(':id/assets')
@Authenticated(Permission.TAG_READ)
getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
return this.service.getAssets(auth, id);
}
@Put(':id/assets')
@Authenticated(Permission.TAG_UPDATE)
tagAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -55,6 +61,7 @@ export class TagController {
}
@Delete(':id/assets')
@Authenticated(Permission.TAG_UPDATE)
untagAssets(
@Auth() auth: AuthDto,
@Body() dto: AssetIdsDto,

View File

@@ -1,24 +1,23 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TimelineService } from 'src/services/timeline.service';
@ApiTags('Timeline')
@Controller('timeline')
@Authenticated()
export class TimelineController {
constructor(private service: TimelineService) {}
@Authenticated({ isShared: true })
@Authenticated(Permission.ASSET_READ, { sharedLink: true })
@Get('buckets')
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
return this.service.getTimeBuckets(auth, dto);
}
@Authenticated({ isShared: true })
@Authenticated(Permission.ASSET_READ, { sharedLink: true })
@Get('bucket')
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>;

View File

@@ -1,29 +1,31 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TrashService } from 'src/services/trash.service';
@ApiTags('Trash')
@Controller('trash')
@Authenticated()
export class TrashController {
constructor(private service: TrashService) {}
@Post('empty')
@Authenticated(Permission.ASSET_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
emptyTrash(@Auth() auth: AuthDto): Promise<void> {
return this.service.empty(auth);
}
@Post('restore')
@Authenticated(Permission.ASSET_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
restoreTrash(@Auth() auth: AuthDto): Promise<void> {
return this.service.restore(auth);
}
@Post('restore/assets')
@Authenticated(Permission.ASSET_DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.restoreAssets(auth, dto);

View File

@@ -16,10 +16,10 @@ import {
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } from 'src/dtos/user.dto';
import { AdminRoute, Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
import { UserService } from 'src/services/user.service';
import { sendFile } from 'src/utils/file';
@@ -27,39 +27,42 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('User')
@Controller(Route.USER)
@Authenticated()
export class UserController {
constructor(private service: UserService) {}
@Get()
@Authenticated(Permission.USER_READ)
getAllUsers(@Auth() auth: AuthDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> {
return this.service.getAll(auth, isAll);
}
@Get('info/:id')
@Authenticated(Permission.USER_READ)
getUserById(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.get(id);
}
@Get('me')
@Authenticated(Permission.USER_READ)
getMyUserInfo(@Auth() auth: AuthDto): Promise<UserResponseDto> {
return this.service.getMe(auth);
}
@AdminRoute()
@Post()
@Authenticated(Permission.USER_CREATE)
createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.service.create(createUserDto);
}
@Delete('profile-image')
@Authenticated(Permission.USER_UPDATE)
@HttpCode(HttpStatus.NO_CONTENT)
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@AdminRoute()
@Delete(':id')
@Authenticated(Permission.USER_DELETE)
deleteUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -68,14 +71,15 @@ export class UserController {
return this.service.delete(auth, id, dto);
}
@AdminRoute()
@Post(':id/restore')
@Authenticated(Permission.USER_DELETE)
restoreUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.restore(auth, id);
}
// TODO: replace with @Put(':id')
@Put()
@Authenticated(Permission.USER_UPDATE)
updateUser(@Auth() auth: AuthDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
return this.service.update(auth, updateUserDto);
}
@@ -84,6 +88,7 @@ export class UserController {
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Post('profile-image')
@Authenticated(Permission.USER_UPDATE)
createProfileImage(
@Auth() auth: AuthDto,
@UploadedFile() fileInfo: Express.Multer.File,
@@ -92,6 +97,7 @@ export class UserController {
}
@Get('profile-image/:id')
@Authenticated(Permission.USER_READ)
@FileResponse()
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
await sendFile(res, next, () => this.service.getProfileImage(id));

View File

@@ -4,7 +4,7 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { setDifference, setIsEqual, setUnion } from 'src/utils/set';
export enum Permission {
export enum AccessPermission {
ACTIVITY_CREATE = 'activity.create',
ACTIVITY_DELETE = 'activity.delete',
@@ -74,7 +74,7 @@ export class AccessCore {
* Check if user has access to all ids, for the given permission.
* Throws error if user does not have access to any of the ids.
*/
async requirePermission(auth: AuthDto, permission: Permission, ids: string[] | string) {
async requirePermission(auth: AuthDto, permission: AccessPermission, ids: string[] | string) {
ids = Array.isArray(ids) ? ids : [ids];
const allowedIds = await this.checkAccess(auth, permission, ids);
if (!setIsEqual(new Set(ids), allowedIds)) {
@@ -88,7 +88,7 @@ export class AccessCore {
*
* @returns Set<string>
*/
async checkAccess(auth: AuthDto, permission: Permission, ids: Set<string> | string[]): Promise<Set<string>> {
async checkAccess(auth: AuthDto, permission: AccessPermission, ids: Set<string> | string[]): Promise<Set<string>> {
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
if (idSet.size === 0) {
return new Set();
@@ -103,40 +103,40 @@ export class AccessCore {
private async checkAccessSharedLink(
sharedLink: SharedLinkEntity,
permission: Permission,
permission: AccessPermission,
ids: Set<string>,
): Promise<Set<string>> {
const sharedLinkId = sharedLink.id;
switch (permission) {
case Permission.ASSET_READ: {
case AccessPermission.ASSET_READ: {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ASSET_VIEW: {
case AccessPermission.ASSET_VIEW: {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ASSET_DOWNLOAD: {
case AccessPermission.ASSET_DOWNLOAD: {
return sharedLink.allowDownload
? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
}
case Permission.ASSET_UPLOAD: {
case AccessPermission.ASSET_UPLOAD: {
return sharedLink.allowUpload ? ids : new Set();
}
case Permission.ASSET_SHARE: {
case AccessPermission.ASSET_SHARE: {
// TODO: fix this to not use sharedLink.userId for access control
return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids);
}
case Permission.ALBUM_READ: {
case AccessPermission.ALBUM_READ: {
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ALBUM_DOWNLOAD: {
case AccessPermission.ALBUM_DOWNLOAD: {
return sharedLink.allowDownload
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
@@ -148,15 +148,15 @@ export class AccessCore {
}
}
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>): Promise<Set<string>> {
private async checkAccessOther(auth: AuthDto, permission: AccessPermission, ids: Set<string>): Promise<Set<string>> {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE: {
case AccessPermission.ACTIVITY_CREATE: {
return await this.repository.activity.checkCreateAccess(auth.user.id, ids);
}
// uses activity id
case Permission.ACTIVITY_DELETE: {
case AccessPermission.ACTIVITY_DELETE: {
const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids);
const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess(
auth.user.id,
@@ -165,7 +165,7 @@ export class AccessCore {
return setUnion(isOwner, isAlbumOwner);
}
case Permission.ASSET_READ: {
case AccessPermission.ASSET_READ: {
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await this.repository.asset.checkPartnerAccess(
@@ -175,13 +175,13 @@ export class AccessCore {
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_SHARE: {
case AccessPermission.ASSET_SHARE: {
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isPartner = await this.repository.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.ASSET_VIEW: {
case AccessPermission.ASSET_VIEW: {
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await this.repository.asset.checkPartnerAccess(
@@ -191,7 +191,7 @@ export class AccessCore {
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_DOWNLOAD: {
case AccessPermission.ASSET_DOWNLOAD: {
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await this.repository.asset.checkPartnerAccess(
@@ -201,101 +201,101 @@ export class AccessCore {
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_UPDATE: {
case AccessPermission.ASSET_UPDATE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ASSET_DELETE: {
case AccessPermission.ASSET_DELETE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ASSET_RESTORE: {
case AccessPermission.ASSET_RESTORE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_READ: {
case AccessPermission.ALBUM_READ: {
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared);
}
case Permission.ALBUM_UPDATE: {
case AccessPermission.ALBUM_UPDATE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_DELETE: {
case AccessPermission.ALBUM_DELETE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_SHARE: {
case AccessPermission.ALBUM_SHARE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_DOWNLOAD: {
case AccessPermission.ALBUM_DOWNLOAD: {
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared);
}
case Permission.ALBUM_REMOVE_ASSET: {
case AccessPermission.ALBUM_REMOVE_ASSET: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ASSET_UPLOAD: {
case AccessPermission.ASSET_UPLOAD: {
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ARCHIVE_READ: {
case AccessPermission.ARCHIVE_READ: {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.AUTH_DEVICE_DELETE: {
case AccessPermission.AUTH_DEVICE_DELETE: {
return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids);
}
case Permission.TIMELINE_READ: {
case AccessPermission.TIMELINE_READ: {
const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
const isPartner = await this.repository.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.TIMELINE_DOWNLOAD: {
case AccessPermission.TIMELINE_DOWNLOAD: {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.MEMORY_READ: {
case AccessPermission.MEMORY_READ: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.MEMORY_WRITE: {
case AccessPermission.MEMORY_WRITE: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.MEMORY_DELETE: {
case AccessPermission.MEMORY_DELETE: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ: {
case AccessPermission.PERSON_READ: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_WRITE: {
case AccessPermission.PERSON_WRITE: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_MERGE: {
case AccessPermission.PERSON_MERGE: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_CREATE: {
case AccessPermission.PERSON_CREATE: {
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_REASSIGN: {
case AccessPermission.PERSON_REASSIGN: {
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PARTNER_UPDATE: {
case AccessPermission.PARTNER_UPDATE: {
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
}

View File

@@ -48,6 +48,10 @@ export class UserCore {
throw new BadRequestException('The server already has an admin');
}
if (dto.permissions) {
// TODO validate granted permissions
}
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) {
@@ -93,6 +97,11 @@ export class UserCore {
if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
}
if (payload.permissions) {
// TODO validate permissions
}
const userEntity = await this.userRepository.create(payload);
await this.libraryRepository.create({
owner: { id: userEntity.id } as UserEntity,

View File

@@ -25,6 +25,195 @@ export type CookieResponse = {
values: Array<{ key: ImmichCookie; value: string }>;
};
export enum PermissionPreset {
USER = 'user',
ADMIN = 'admin',
CUSTOM = 'custom',
}
export enum Permission {
ACTIVITY_CREATE = 'activity.create',
ACTIVITY_READ = 'activity.read',
ACTIVITY_UPDATE = 'activity.update',
ACTIVITY_DELETE = 'activity.delete',
ALBUM_CREATE = 'album.create',
ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
ASSET_CREATE = 'asset.create',
ASSET_READ = 'asset.read',
ASSET_UPDATE = 'asset.update',
ASSET_DELETE = 'asset.delete',
API_KEY_CREATE = 'apiKey.create',
API_KEY_READ = 'apiKey.read',
API_KEY_UPDATE = 'apiKey.update',
API_KEY_DELETE = 'apiKey.delete',
AUTH_DEVICE_CREATE = 'authDevice.create',
AUTH_DEVICE_READ = 'authDevice.read',
AUTH_DEVICE_UPDATE = 'authDevice.update',
AUTH_DEVICE_DELETE = 'authDevice.delete',
FACE_CREATE = 'face.create',
FACE_READ = 'face.read',
FACE_UPDATE = 'face.update',
FACE_DELETE = 'face.delete',
LIBRARY_CREATE = 'library.create',
LIBRARY_READ = 'library.read',
LIBRARY_UPDATE = 'library.update',
LIBRARY_DELETE = 'library.delete',
MEMORY_CREATE = 'memory.create',
MEMORY_READ = 'memory.read',
MEMORY_UPDATE = 'memory.update',
MEMORY_DELETE = 'memory.delete',
MEMORY_ADD_ASSET = 'memory.addAsset',
MEMORY_REMOVE_ASSET = 'memory.removeAsset',
PARTNER_CREATE = 'partner.create',
PARTNER_READ = 'partner.read',
PARTNER_UPDATE = 'partner.update',
PARTNER_DELETE = 'partner.delete',
PERSON_CREATE = 'person.create',
PERSON_READ = 'person.read',
PERSON_UPDATE = 'person.update',
PERSON_DELETE = 'person.delete',
REPORT_CREATE = 'report.create',
REPORT_READ = 'report.read',
REPORT_UPDATE = 'report.update',
REPORT_DELETE = 'report.delete',
SESSION_CREATE = 'session.create',
SESSION_READ = 'session.read',
SESSION_UPDATE = 'session.update',
SESSION_DELETE = 'session.delete',
SHARED_LINK_CREATE = 'sharedLink.create',
SHARED_LINK_READ = 'sharedLink.read',
SHARED_LINK_UPDATE = 'sharedLink.update',
SHARED_LINK_DELETE = 'sharedLink.delete',
SYSTEM_CONFIG_CREATE = 'systemConfig.create',
SYSTEM_CONFIG_READ = 'systemConfig.read',
SYSTEM_CONFIG_UPDATE = 'systemConfig.update',
SYSTEM_CONFIG_DELETE = 'systemConfig.delete',
SYSTEM_METADATA_CREATE = 'systemMetadata.create',
SYSTEM_METADATA_READ = 'systemMetadata.read',
SYSTEM_METADATA_UPDATE = 'systemMetadata.update',
SYSTEM_METADATA_DELETE = 'systemMetadata.delete',
STACK_CREATE = 'stack.create',
STACK_READ = 'stack.read',
STACK_UPDATE = 'stack.update',
STACK_DELETE = 'stack.delete',
TAG_CREATE = 'tag.create',
TAG_READ = 'tag.read',
TAG_UPDATE = 'tag.update',
TAG_DELETE = 'tag.delete',
USER_CREATE = 'user.create',
USER_READ = 'user.read',
USER_UPDATE = 'user.update',
USER_DELETE = 'user.delete',
// other
AUTH_CHANGE_PASSWORD = 'auth.changePassword',
AUTH_OAUTH = 'auth.oauth',
ALBUM_ADD_ASSET = 'album.addAsset',
ALBUM_REMOVE_ASSET = 'album.removeAsset',
ALBUM_ADD_USER = 'album.addUser',
ALBUM_REMOVE_USER = 'album.removeUser',
ASSET_VIEW_THUMB = 'asset.viewThumb',
ASSET_VIEW_PREVIEW = 'asset.viewPreview',
ASSET_VIEW_ORIGINAL = 'asset.viewOriginal',
ASSET_UPLOAD = 'asset.upload',
ASSET_DOWNLOAD = 'asset.download',
JOB_READ = 'job.read',
JOB_RUN = 'job.run',
MAP_READ = 'map.read',
USER_READ_SIMPLE = 'user.readSimple',
USER_CHANGE_PASSWORD = 'user.changePassword',
SERVER_READ = 'server.read',
SERVER_SETUP = 'server.setup',
}
export const presetToPermissions = ({
permissionPreset: preset,
permissions,
}: {
permissionPreset?: PermissionPreset;
permissions?: Permission[];
}) => {
switch (preset) {
case PermissionPreset.ADMIN: {
return ALL_PERMISSIONS;
}
case PermissionPreset.USER: {
return USER_PERMISSIONS;
}
case PermissionPreset.CUSTOM: {
return permissions ?? [];
}
default: {
return;
}
}
};
export const ALL_PERMISSIONS = Object.values(Permission);
export const USER_PERMISSIONS = ALL_PERMISSIONS.filter((permission) => {
switch (permission) {
case Permission.JOB_READ:
case Permission.JOB_RUN:
case Permission.LIBRARY_READ:
case Permission.LIBRARY_CREATE:
case Permission.LIBRARY_UPDATE:
case Permission.LIBRARY_DELETE:
// TODO this can't be an admin permission yet because non-admins still use it
case Permission.USER_READ:
case Permission.USER_CREATE:
case Permission.USER_UPDATE:
case Permission.USER_DELETE:
case Permission.SYSTEM_CONFIG_CREATE:
case Permission.SYSTEM_CONFIG_READ:
case Permission.SYSTEM_CONFIG_UPDATE:
case Permission.SYSTEM_CONFIG_DELETE:
case Permission.SYSTEM_METADATA_CREATE:
case Permission.SYSTEM_METADATA_READ:
case Permission.SYSTEM_METADATA_UPDATE:
case Permission.SYSTEM_METADATA_DELETE: {
return false;
}
default: {
return true;
}
}
});
export type AuthorizationPermissions = Set<Permission>;
export class AuthDto {
user!: UserEntity;

View File

@@ -1,5 +1,6 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { PermissionPreset } from 'src/dtos/auth.dto';
import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto';
describe('update user DTO', () => {
@@ -22,6 +23,7 @@ describe('create user DTO', () => {
email: undefined,
password: 'password',
name: 'name',
permissionPreset: PermissionPreset.USER,
};
let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
let errors = await validate(dto);
@@ -45,6 +47,7 @@ describe('create user DTO', () => {
email: someEmail,
password: 'some password',
name: 'some name',
permissionPreset: 'user',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);

View File

@@ -1,10 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID, ValidateIf } from 'class-validator';
import { Permission, PermissionPreset } from 'src/dtos/auth.dto';
import { getRandomAvatarColor } from 'src/dtos/user-profile.dto';
import { UserAvatarColor, UserEntity, UserStatus } from 'src/entities/user.entity';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
const isCustomPreset = ({ permissionPreset }: CreateUserDto) =>
permissionPreset && permissionPreset === PermissionPreset.CUSTOM;
export class CreateUserDto {
@IsEmail({ require_tld: false })
@Transform(toEmail)
@@ -34,6 +38,15 @@ export class CreateUserDto {
@ValidateBoolean({ optional: true })
shouldChangePassword?: boolean;
@IsEnum(PermissionPreset)
@ApiProperty({ enum: PermissionPreset, enumName: 'PermissionPreset' })
permissionPreset!: PermissionPreset;
@ValidateIf(isCustomPreset)
@IsEnum(Permission, { each: true })
@ApiPropertyOptional({ enum: Permission, enumName: 'AuthorizationPermission' })
permissions?: Permission[];
}
export class CreateAdminDto {
@@ -112,6 +125,16 @@ export class UpdateUserDto {
@IsPositive()
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
@Optional()
@IsEnum(PermissionPreset)
@ApiProperty({ enum: PermissionPreset, enumName: 'PermissionPreset' })
permissionPreset?: PermissionPreset;
@ValidateIf(isCustomPreset)
@IsEnum(Permission, { each: true })
@ApiPropertyOptional({ enum: Permission, enumName: 'AuthorizationPermission' })
permissions?: Permission[];
}
export class UserDto {
@@ -139,6 +162,7 @@ export class UserResponseDto extends UserDto {
quotaUsageInBytes!: number | null;
@ApiProperty({ enumName: 'UserStatus', enum: UserStatus })
status!: string;
permissions?: Permission[];
}
export const mapSimpleUser = (entity: UserEntity): UserDto => {
@@ -165,5 +189,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,
permissions: entity.permissions,
};
}

View File

@@ -1,3 +1,4 @@
import { Permission } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity';
import {
@@ -87,4 +88,7 @@ export class UserEntity {
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: number;
@Column({ type: 'varchar', array: true })
permissions!: Permission[];
}

View File

@@ -2,10 +2,12 @@ import { SessionEntity } from 'src/entities/session.entity';
export const ISessionRepository = 'ISessionRepository';
type E = SessionEntity;
export interface ISessionRepository {
create(dto: Partial<SessionEntity>): Promise<SessionEntity>;
update(dto: Partial<SessionEntity>): Promise<SessionEntity>;
create<T extends Partial<E>>(dto: T): Promise<T>;
update<T extends Partial<E>>(dto: T): Promise<T>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<SessionEntity | null>;
getByUserId(userId: string): Promise<SessionEntity[]>;
getByToken(token: string): Promise<E | null>;
getByUserId(userId: string): Promise<E[]>;
}

View File

@@ -10,49 +10,40 @@ import {
import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, Permission } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UAParser } from 'ua-parser-js';
export enum Metadata {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
PUBLIC_SECURITY = 'public_security',
API_KEY_SECURITY = 'api_key',
PERMISSION = 'auth_permission',
}
export interface AuthenticatedOptions {
admin?: true;
isShared?: true;
}
type AuthenticatedOptions = {
sharedLink?: true;
/** skip permission check when param id matches calling user */
bypassParamId?: string;
};
export const Authenticated = (options: AuthenticatedOptions = {}) => {
const decorators: MethodDecorator[] = [
export const Authenticated = (permission: Permission, options?: AuthenticatedOptions) => {
const { sharedLink } = { sharedLink: false, ...options };
const decorators = sharedLink
? [SetMetadata(Metadata.SHARED_ROUTE, true), ApiQuery({ name: 'key', type: String, required: false })]
: [];
return applyDecorators(
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(Metadata.API_KEY_SECURITY),
SetMetadata(Metadata.AUTH_ROUTE, true),
];
if (options.admin) {
decorators.push(AdminRoute());
}
if (options.isShared) {
decorators.push(SharedLinkRoute());
}
return applyDecorators(...decorators);
SetMetadata(Metadata.PERMISSION, permission),
...decorators,
);
};
export const PublicRoute = () =>
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
export const SharedLinkRoute = () =>
applyDecorators(SetMetadata(Metadata.SHARED_ROUTE, true), ApiQuery({ name: 'key', type: String, required: false }));
export const AdminRoute = (value = true) => SetMetadata(Metadata.ADMIN_ROUTE, value);
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
return context.switchToHttp().getRequest<{ user: AuthDto }>().user;
});
@@ -89,26 +80,29 @@ export class AuthGuard implements CanActivate {
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
const method = context.getHandler();
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
const permission = this.reflector.get<Permission>(Metadata.PERMISSION, method);
const isSharedRoute = this.reflector.get<boolean>(Metadata.SHARED_ROUTE, method);
if (!isAuthRoute) {
// public
if (!permission) {
return true;
}
const request = context.switchToHttp().getRequest<AuthRequest>();
const authDto = await this.authService.validate(request.headers, request.query as Record<string, string>);
const isApiKey = !!authDto.apiKey;
const isUserToken = !!authDto.session;
if (authDto.sharedLink && !isSharedRoute) {
this.logger.warn(`Denied access to non-shared route: ${request.path}`);
return false;
}
if (isAdminRoute && !authDto.user.isAdmin) {
this.logger.warn(`Denied access to admin only route: ${request.path}`);
if ((isApiKey || isUserToken) && !authDto.user.permissions.includes(permission)) {
this.logger.warn(`Denied access to route: no ${permission} permission: ${request.path}. `);
return false;
}

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserPermissions1713389653857 implements MigrationInterface {
name = 'AddUserPermissions1713389653857'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "permissions" character varying array NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "permissions"`);
}
}

View File

@@ -31,12 +31,12 @@ export class SessionRepository implements ISessionRepository {
});
}
create(session: Partial<SessionEntity>): Promise<SessionEntity> {
return this.repository.save(session);
create<T extends Partial<SessionEntity>>(dto: T): Promise<T & { id: string }> {
return this.repository.save(dto);
}
update(session: Partial<SessionEntity>): Promise<SessionEntity> {
return this.repository.save(session);
update<T extends Partial<SessionEntity>>(dto: T): Promise<T> {
return this.repository.save(dto);
}
@GenerateSql({ params: [DummyValue.UUID] })

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import {
ActivityCreateDto,
ActivityDto,
@@ -28,7 +28,7 @@ export class ActivityService {
}
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId);
await this.access.requirePermission(auth, AccessPermission.ALBUM_READ, dto.albumId);
const activities = await this.repository.search({
userId: dto.userId,
albumId: dto.albumId,
@@ -40,12 +40,12 @@ export class ActivityService {
}
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId);
await this.access.requirePermission(auth, AccessPermission.ALBUM_READ, dto.albumId);
return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) };
}
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
await this.access.requirePermission(auth, Permission.ACTIVITY_CREATE, dto.albumId);
await this.access.requirePermission(auth, AccessPermission.ACTIVITY_CREATE, dto.albumId);
const common = {
userId: auth.user.id,
@@ -79,7 +79,7 @@ export class ActivityService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.ACTIVITY_DELETE, id);
await this.access.requirePermission(auth, AccessPermission.ACTIVITY_DELETE, id);
await this.repository.delete(id);
}
}

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import {
AddUsersDto,
AlbumCountResponseDto,
@@ -97,7 +97,7 @@ export class AlbumService {
}
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(auth, Permission.ALBUM_READ, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails();
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets });
@@ -119,7 +119,7 @@ export class AlbumService {
}
}
const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds));
const allowedAssetIdsSet = await this.access.checkAccess(auth, AccessPermission.ASSET_SHARE, new Set(dto.assetIds));
const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity);
const album = await this.albumRepository.create({
@@ -135,7 +135,7 @@ export class AlbumService {
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(auth, Permission.ALBUM_UPDATE, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_UPDATE, id);
const album = await this.findOrFail(id, { withAssets: true });
@@ -158,7 +158,7 @@ export class AlbumService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.ALBUM_DELETE, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_DELETE, id);
const album = await this.findOrFail(id, { withAssets: false });
@@ -167,7 +167,7 @@ export class AlbumService {
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(auth, Permission.ALBUM_READ, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_READ, id);
const results = await addAssets(
auth,
@@ -190,12 +190,12 @@ export class AlbumService {
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(auth, Permission.ALBUM_READ, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_READ, id);
const results = await removeAssets(
auth,
{ accessRepository: this.accessRepository, repository: this.albumRepository },
{ id, assetIds: dto.ids, permissions: [Permission.ASSET_SHARE, Permission.ALBUM_REMOVE_ASSET] },
{ id, assetIds: dto.ids, permissions: [AccessPermission.ASSET_SHARE, AccessPermission.ALBUM_REMOVE_ASSET] },
);
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
@@ -210,7 +210,7 @@ export class AlbumService {
}
async addUsers(auth: AuthDto, id: string, dto: AddUsersDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_SHARE, id);
const album = await this.findOrFail(id, { withAssets: false });
@@ -259,7 +259,7 @@ export class AlbumService {
// non-admin can remove themselves
if (auth.user.id !== userId) {
await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id);
await this.access.requirePermission(auth, AccessPermission.ALBUM_SHARE, id);
}
await this.albumRepository.update({

View File

@@ -5,7 +5,7 @@ import {
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import {
AssetBulkUploadCheckResponseDto,
@@ -78,7 +78,7 @@ export class AssetServiceV1 {
try {
const libraryId = await this.getLibraryId(auth, dto.libraryId);
await this.access.requirePermission(auth, Permission.ASSET_UPLOAD, libraryId);
await this.access.requirePermission(auth, AccessPermission.ASSET_UPLOAD, libraryId);
this.requireQuota(auth, file.size);
if (livePhotoFile) {
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId };
@@ -111,13 +111,13 @@ export class AssetServiceV1 {
public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
await this.access.requirePermission(auth, AccessPermission.TIMELINE_READ, userId);
const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset, { withStack: true, auth }));
}
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
await this.access.requirePermission(auth, AccessPermission.ASSET_VIEW, assetId);
const asset = await this.assetRepositoryV1.get(assetId);
if (!asset) {
@@ -135,7 +135,7 @@ export class AssetServiceV1 {
public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise<ImmichFileResponse> {
// this is not quite right as sometimes this returns the original still
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
await this.access.requirePermission(auth, AccessPermission.ASSET_VIEW, assetId);
const asset = await this.assetRepository.getById(assetId);
if (!asset) {

View File

@@ -3,7 +3,7 @@ import _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import {
@@ -210,7 +210,7 @@ export class AssetService {
}
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_READ, id);
await this.access.requirePermission(auth, AccessPermission.ASSET_READ, id);
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
@@ -250,7 +250,7 @@ export class AssetService {
}
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
await this.access.requirePermission(auth, AccessPermission.ASSET_UPDATE, id);
const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
@@ -273,7 +273,7 @@ export class AssetService {
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto;
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids);
await this.access.requirePermission(auth, AccessPermission.ASSET_UPDATE, ids);
// TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc.
const stackIdsToCheckForDelete: string[] = [];
@@ -289,7 +289,7 @@ export class AssetService {
);
} else if (options.stackParentId) {
//Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId);
await this.access.requirePermission(auth, AccessPermission.ASSET_UPDATE, options.stackParentId);
const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } });
if (!primaryAsset) {
throw new BadRequestException('Asset not found for given stackParentId');
@@ -418,7 +418,7 @@ export class AssetService {
async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> {
const { ids, force } = dto;
await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids);
await this.access.requirePermission(auth, AccessPermission.ASSET_DELETE, ids);
if (force) {
await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.ASSET_DELETION, data: { id } })));
@@ -430,8 +430,8 @@ export class AssetService {
async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise<void> {
const { oldParentId, newParentId } = dto;
await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId);
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, newParentId);
await this.access.requirePermission(auth, AccessPermission.ASSET_READ, oldParentId);
await this.access.requirePermission(auth, AccessPermission.ASSET_UPDATE, newParentId);
const childIds: string[] = [];
const oldParent = await this.assetRepository.getById(oldParentId, {
@@ -464,7 +464,7 @@ export class AssetService {
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds);
await this.access.requirePermission(auth, AccessPermission.ASSET_UPDATE, dto.assetIds);
const jobs: JobItem[] = [];

View File

@@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import {
AuditDeletesDto,
@@ -51,7 +51,7 @@ export class AuditService {
async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
await this.access.requirePermission(auth, AccessPermission.TIMELINE_READ, userId);
const audits = await this.repository.getAfter(dto.after, {
userIds: [userId],

View File

@@ -55,6 +55,7 @@ const oauthUserWithDefaultQuota = {
oauthId: sub,
quotaSizeInBytes: 1_073_741_824,
storageLabel: null,
permissions: expect.any(Array),
};
describe('AuthService', () => {
@@ -340,10 +341,7 @@ describe('AuthService', () => {
sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
sessionMock.update.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
session: sessionStub.valid,
});
await expect(sut.validate(headers, {})).resolves.toBeDefined();
expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
});
});
@@ -495,6 +493,7 @@ describe('AuthService', () => {
oauthId: sub,
quotaSizeInBytes: null,
storageLabel: null,
permissions: expect.any(Array),
});
});
@@ -515,6 +514,7 @@ describe('AuthService', () => {
oauthId: sub,
quotaSizeInBytes: 5_368_709_120,
storageLabel: null,
permissions: expect.any(Array),
});
});
});

View File

@@ -15,6 +15,7 @@ import { AccessCore } from 'src/cores/access.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import {
ALL_PERMISSIONS,
AuthDto,
ChangePasswordDto,
ImmichCookie,
@@ -25,6 +26,7 @@ import {
OAuthCallbackDto,
OAuthConfigDto,
SignUpDto,
USER_PERMISSIONS,
mapLoginResponse,
} from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
@@ -143,6 +145,7 @@ export class AuthService {
name: dto.name,
password: dto.password,
storageLabel: 'admin',
permissions: ALL_PERMISSIONS,
});
return mapUser(admin);
@@ -238,6 +241,7 @@ export class AuthService {
oauthId: profile.sub,
quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
storageLabel: storageLabel || null,
permissions: USER_PERMISSIONS,
});
}
@@ -374,14 +378,14 @@ export class AuthService {
private async validateSession(tokenValue: string): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let session = await this.sessionRepository.getByToken(hashedToken);
const session = await this.sessionRepository.getByToken(hashedToken);
if (session?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(session.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
}
return { user: session.user, session: session };

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { parse } from 'node:path';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
@@ -26,7 +26,7 @@ export class DownloadService {
}
async downloadFile(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
await this.access.requirePermission(auth, AccessPermission.ASSET_DOWNLOAD, id);
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
@@ -81,7 +81,7 @@ export class DownloadService {
}
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, dto.assetIds);
await this.access.requirePermission(auth, AccessPermission.ASSET_DOWNLOAD, dto.assetIds);
const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getByIds(dto.assetIds);
@@ -117,20 +117,20 @@ export class DownloadService {
if (dto.assetIds) {
const assetIds = dto.assetIds;
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds);
await this.access.requirePermission(auth, AccessPermission.ASSET_DOWNLOAD, assetIds);
const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true });
return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets }));
}
if (dto.albumId) {
const albumId = dto.albumId;
await this.access.requirePermission(auth, Permission.ALBUM_DOWNLOAD, albumId);
await this.access.requirePermission(auth, AccessPermission.ALBUM_DOWNLOAD, albumId);
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId));
}
if (dto.userId) {
const userId = dto.userId;
await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId);
await this.access.requirePermission(auth, AccessPermission.TIMELINE_DOWNLOAD, userId);
return usePagination(PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, userId, { isVisible: true }),
);

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
@@ -25,7 +25,7 @@ export class MemoryService {
}
async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> {
await this.access.requirePermission(auth, Permission.MEMORY_READ, id);
await this.access.requirePermission(auth, AccessPermission.MEMORY_READ, id);
const memory = await this.findOrFail(id);
return mapMemory(memory);
}
@@ -34,7 +34,7 @@ export class MemoryService {
// TODO validate type/data combination
const assetIds = dto.assetIds || [];
const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds);
const allowedAssetIds = await this.access.checkAccess(auth, AccessPermission.ASSET_SHARE, assetIds);
const memory = await this.repository.create({
ownerId: auth.user.id,
type: dto.type,
@@ -49,7 +49,7 @@ export class MemoryService {
}
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
await this.access.requirePermission(auth, AccessPermission.MEMORY_WRITE, id);
const memory = await this.repository.update({
id,
@@ -62,12 +62,12 @@ export class MemoryService {
}
async remove(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id);
await this.access.requirePermission(auth, AccessPermission.MEMORY_DELETE, id);
await this.repository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.access.requirePermission(auth, Permission.MEMORY_READ, id);
await this.access.requirePermission(auth, AccessPermission.MEMORY_READ, id);
const repos = { accessRepository: this.accessRepository, repository: this.repository };
const results = await addAssets(auth, repos, { id, assetIds: dto.ids });
@@ -81,10 +81,10 @@ export class MemoryService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
await this.access.requirePermission(auth, AccessPermission.MEMORY_WRITE, id);
const repos = { accessRepository: this.accessRepository, repository: this.repository };
const permissions = [Permission.ASSET_SHARE];
const permissions = [AccessPermission.ASSET_SHARE];
const results = await removeAssets(auth, repos, { id, assetIds: dto.ids, permissions });
const hasSuccess = results.find(({ success }) => success);

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
@@ -48,7 +48,7 @@ export class PartnerService {
}
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
await this.access.requirePermission(auth, Permission.PARTNER_UPDATE, sharedById);
await this.access.requirePermission(auth, AccessPermission.PARTNER_UPDATE, sharedById);
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -101,7 +101,7 @@ export class PersonService {
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, AccessPermission.PERSON_WRITE, personId);
const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
@@ -109,7 +109,7 @@ export class PersonService {
const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
for (const face of faces) {
await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id);
await this.access.requirePermission(auth, AccessPermission.PERSON_CREATE, face.id);
if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id);
}
@@ -130,9 +130,9 @@ export class PersonService {
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, AccessPermission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id);
await this.access.requirePermission(auth, AccessPermission.PERSON_CREATE, dto.id);
const face = await this.repository.getFaceById(dto.id);
const person = await this.findOrFail(personId);
@@ -148,7 +148,7 @@ export class PersonService {
}
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.access.requirePermission(auth, Permission.ASSET_READ, dto.id);
await this.access.requirePermission(auth, AccessPermission.ASSET_READ, dto.id);
const faces = await this.repository.getFaces(dto.id);
return faces.map((asset) => mapFaces(asset, auth));
}
@@ -175,17 +175,17 @@ export class PersonService {
}
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_READ, id);
await this.access.requirePermission(auth, AccessPermission.PERSON_READ, id);
return this.findOrFail(id).then(mapPerson);
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_READ, id);
await this.access.requirePermission(auth, AccessPermission.PERSON_READ, id);
return this.repository.getStatistics(id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.PERSON_READ, id);
await this.access.requirePermission(auth, AccessPermission.PERSON_READ, id);
const person = await this.repository.getById(id);
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
@@ -199,7 +199,7 @@ export class PersonService {
}
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
await this.access.requirePermission(auth, Permission.PERSON_READ, id);
await this.access.requirePermission(auth, AccessPermission.PERSON_READ, id);
const assets = await this.repository.getAssets(id);
return assets.map((asset) => mapAsset(asset));
}
@@ -214,13 +214,13 @@ export class PersonService {
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
await this.access.requirePermission(auth, AccessPermission.PERSON_WRITE, id);
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
await this.access.requirePermission(auth, Permission.ASSET_READ, assetId);
await this.access.requirePermission(auth, AccessPermission.ASSET_READ, assetId);
const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]);
if (!face) {
throw new BadRequestException('Invalid assetId for feature face');
@@ -555,13 +555,13 @@ export class PersonService {
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
await this.access.requirePermission(auth, AccessPermission.PERSON_WRITE, id);
let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = [];
const allowedIds = await this.access.checkAccess(auth, Permission.PERSON_MERGE, mergeIds);
const allowedIds = await this.access.checkAccess(auth, AccessPermission.PERSON_MERGE, mergeIds);
for (const mergeId of mergeIds) {
const hasAccess = allowedIds.has(mergeId);

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
@@ -25,7 +25,7 @@ export class SessionService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
await this.access.requirePermission(auth, AccessPermission.AUTH_DEVICE_DELETE, id);
await this.sessionRepository.delete(id);
}

View File

@@ -1,5 +1,5 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -59,7 +59,7 @@ export class SharedLinkService {
if (!dto.albumId) {
throw new BadRequestException('Invalid albumId');
}
await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId);
await this.access.requirePermission(auth, AccessPermission.ALBUM_SHARE, dto.albumId);
break;
}
@@ -68,7 +68,7 @@ export class SharedLinkService {
throw new BadRequestException('Invalid assetIds');
}
await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds);
await this.access.requirePermission(auth, AccessPermission.ASSET_SHARE, dto.assetIds);
break;
}
@@ -129,7 +129,7 @@ export class SharedLinkService {
const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id));
const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId));
const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds);
const allowedAssetIds = await this.access.checkAccess(auth, AccessPermission.ASSET_SHARE, notPresentAssetIds);
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {

View File

@@ -2,7 +2,7 @@ import { Inject } from '@nestjs/common';
import _ from 'lodash';
import { DateTime } from 'luxon';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto';
@@ -26,7 +26,7 @@ export class SyncService {
async getAllAssetsForUserFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
await this.access.requirePermission(auth, AccessPermission.TIMELINE_READ, userId);
const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId,
lastCreationDate: dto.lastCreationDate,
@@ -39,7 +39,7 @@ export class SyncService {
}
async getChangesForDeltaSync(auth: AuthDto, dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
await this.access.requirePermission(auth, Permission.TIMELINE_READ, dto.userIds);
await this.access.requirePermission(auth, AccessPermission.TIMELINE_READ, dto.userIds);
const partner = await this.partnerRepository.getAll(auth.user.id);
const userIds = [auth.user.id, ...partner.filter((p) => p.sharedWithId == auth.user.id).map((p) => p.sharedById)];
userIds.sort();

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
@@ -59,15 +59,15 @@ export class TimelineService {
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) {
await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]);
await this.accessCore.requirePermission(auth, AccessPermission.ALBUM_READ, [dto.albumId]);
} else {
dto.userId = dto.userId || auth.user.id;
}
if (dto.userId) {
await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]);
await this.accessCore.requirePermission(auth, AccessPermission.TIMELINE_READ, [dto.userId]);
if (dto.isArchived !== false) {
await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]);
await this.accessCore.requirePermission(auth, AccessPermission.ARCHIVE_READ, [dto.userId]);
}
}

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
@@ -23,7 +23,7 @@ export class TrashService {
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
const { ids } = dto;
await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids);
await this.access.requirePermission(auth, AccessPermission.ASSET_RESTORE, ids);
await this.restoreAndSend(auth, ids);
}

View File

@@ -3,7 +3,7 @@ import { DateTime } from 'luxon';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, presetToPermissions } from 'src/dtos/auth.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
@@ -60,8 +60,9 @@ export class UserService {
return this.findOrFail(auth.user.id, {}).then(mapUser);
}
create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.userCore.createUser(createUserDto).then(mapUser);
create(dto: CreateUserDto): Promise<UserResponseDto> {
const permissions = presetToPermissions(dto);
return this.userCore.createUser({ ...dto, permissions }).then(mapUser);
}
async update(auth: AuthDto, dto: UpdateUserDto): Promise<UserResponseDto> {
@@ -71,7 +72,8 @@ export class UserService {
await this.userRepository.syncUsage(dto.id);
}
return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser);
const permissions = presetToPermissions(dto);
return this.userCore.updateUser(auth.user, dto.id, { ...dto, permissions }).then(mapUser);
}
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> {

View File

@@ -1,4 +1,4 @@
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore, AccessPermission } from 'src/cores/access.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
@@ -20,7 +20,7 @@ export const addAssets = async (
const existingAssetIds = await repository.getAssetIds(dto.id, dto.assetIds);
const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id));
const allowedAssetIds = await access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds);
const allowedAssetIds = await access.checkAccess(auth, AccessPermission.ASSET_SHARE, notPresentAssetIds);
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.assetIds) {
@@ -50,7 +50,7 @@ export const addAssets = async (
export const removeAssets = async (
auth: AuthDto,
repositories: { accessRepository: IAccessRepository; repository: IBulkAsset },
dto: { id: string; assetIds: string[]; permissions: Permission[] },
dto: { id: string; assetIds: string[]; permissions: AccessPermission[] },
) => {
const { accessRepository, repository } = repositories;
const access = AccessCore.create(accessRepository);

View File

@@ -436,7 +436,7 @@ export class AV1Config extends BaseConfig {
export class NVENCConfig extends BaseHWConfig {
getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC];
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1];
}
getBaseInputOptions() {
@@ -566,7 +566,7 @@ export class QSVConfig extends BaseHWConfig {
}
getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9];
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9, VideoCodec.AV1];
}
// recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md

View File

@@ -103,10 +103,6 @@ const patchOpenAPI = (document: OpenAPIObject) => {
continue;
}
if ((operation.security || []).some((item) => !!item[Metadata.PUBLIC_SECURITY])) {
delete operation.security;
}
if (operation.summary === '') {
delete operation.summary;
}

View File

@@ -3,8 +3,8 @@ import { Mocked, vitest } from 'vitest';
export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => {
return {
create: vitest.fn(),
update: vitest.fn(),
create: vitest.fn() as any,
update: vitest.fn() as any,
delete: vitest.fn(),
getByToken: vitest.fn(),
getByUserId: vitest.fn(),

24
web/package-lock.json generated
View File

@@ -1,17 +1,19 @@
{
"name": "immich-web",
"version": "1.102.1",
"version": "1.102.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.102.1",
"version": "1.102.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6",
"buffer": "^6.0.3",
"copy-image-clipboard": "^2.1.2",
@@ -63,7 +65,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.102.1",
"version": "1.102.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
@@ -1590,6 +1592,22 @@
"three": "^0.161.0"
}
},
"node_modules/@photo-sphere-viewer/equirectangular-video-adapter": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.7.2.tgz",
"integrity": "sha512-cAaot52nPqa2p77Xp1humRvuxRIa8cqbZ/XRhA8kBToFLT1Ugh9YBcDD7pM/358JtAjicUbLpT7Ioap9iEigxQ==",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.7.2"
}
},
"node_modules/@photo-sphere-viewer/video-plugin": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.7.2.tgz",
"integrity": "sha512-vrPV9RCr4HsYiORkto1unDPeUkbN2kbyogvNUoLiQ78M4xkPOqoKxtfxCxTYoM+7gECwNL9VTF81+okck498qA==",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.7.2"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.24",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz",

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