diff --git a/.gitattributes b/.gitattributes index 48c4dbdb0..986127fcb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,3 +17,5 @@ web/src/api/open-api/**/*.md -diff -merge web/src/api/open-api/**/*.md linguist-generated=true web/src/api/open-api/**/*.ts -diff -merge web/src/api/open-api/**/*.ts linguist-generated=true + +*.sh text eol=lf diff --git a/README_de_DE.md b/README_de_DE.md index de0d1e81f..9515fff90 100644 --- a/README_de_DE.md +++ b/README_de_DE.md @@ -11,7 +11,7 @@

-

Immich - Hoch performante, selbst gehostete Backup Lösung für Fotos und Videos

+

Immich - Hoch performante, selbst gehostete Backup-Lösung für Fotos und Videos


@@ -32,10 +32,10 @@ ## Warnung -- ⚠️ Das Projekt befindet sich unter **sehr aktiver** Entwicklung. +- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung. - ⚠️ Erwarte Fehler und Änderungen mit Breaking-Changes. - ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.** -- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup Regel für deine wertvollen Fotos und Videos! +- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos! ## Inhalt @@ -54,9 +54,9 @@ Die Hauptdokumentation, inklusive Installationsanleitungen, ist unter https://im ## Demo -Die Web-Demo kannst du unter https://demo.immich.app finden. +Die Web-Demo kannst Du unter https://demo.immich.app finden. -Für die Handy-App kannst du `https://demo.immich.app/api` als `Server Endpoint URL` angeben. +Für die Handy-App kannst Du `https://demo.immich.app/api` als `Server Endpoint URL` angeben. ```bash title="Demo Credential" Die Anmeldedaten @@ -73,8 +73,8 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM | Funktionen | Mobil | Web | | ---------------------------------------------------- | ------ | ----- | | Fotos & Videos hochladen und ansehen | Ja | Ja | -| Automatisches Backup wenn die App offen ist | Ja | n. a. | -| Selektive Auswahl von Alben zum Backup | Ja | n. a. | +| Automatisches Backup wenn die App geöffnet ist | Ja | n. a. | +| Selektive Auswahl von Alben zum Sichern | Ja | n. a. | | Fotos und Videos auf das Gerät herunterladen | Ja | Ja | | Unterstützt mehrere Benutzer | Ja | Ja | | Album und geteilte Alben | Ja | Ja | @@ -82,7 +82,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM | Unterstützt RAW Formate | Ja | Ja | | Metadaten anzeigen (EXIF, Karte) | Ja | Ja | | Suchen nach Metadaten, Objekten, Gesichtern und CLIP | Ja | Ja | -| Administrative Funktionen (Nutzerverwaltung) | Nein | Ja | +| Administrative Funktionen (Benutzerverwaltung) | Nein | Ja | | Backup im Hintergrund | Ja | n. a. | | Virtuelles Scrollen | Ja | Ja | | OAuth Unterstützung | Ja | Ja | @@ -101,22 +101,22 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM ## Unterstütze das Projekt -Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung mir zusätzliche Motivation zu geben um weiterzumachen. +Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung, um mir zusätzliche Motivation zu geben, weiterzumachen. Wie unsere Gastgeber in der [selfhosted.show - In der Episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) gesagt haben, ist dies ein riesiges Unterfangen, welchem das Team und ich uns annehmen. In Zukunft würde ich liebend gerne Vollzeit an dem Projekt arbeiten und bitte daher um Eure Unterstützung. -Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen. +Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach, das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen. ### Spenden -- [Monatliche Spende](https://github.com/sponsors/alextran1502) via GitHub Sponsors -- [Einmalige Spende](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors +- [Monatliche Spende](https://github.com/sponsors/immich-app) via GitHub Sponsors +- [Einmalige Spende](https://github.com/sponsors/immich-app?frequency=one-time&sponsor=immich-app) via GitHub Sponsors - [Librepay](https://liberapay.com/alex.tran1502/) - [buymeacoffee](https://www.buymeacoffee.com/altran1502) - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz -## Unterstützer +## Mitwirkende diff --git a/cli/package-lock.json b/cli/package-lock.json index e51f03cfa..a855a2d8e 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1604,9 +1604,9 @@ } }, "node_modules/@types/node": { - "version": "20.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", - "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", + "version": "20.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz", + "integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -7990,9 +7990,9 @@ } }, "@types/node": { - "version": "20.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", - "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", + "version": "20.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz", + "integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==", "dev": true, "requires": { "undici-types": "~5.26.4" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 0c9de8d34..fcc2fa9d1 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -108,11 +108,11 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5 + image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 database: container_name: immich_postgres - image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad + image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0 env_file: - .env environment: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index eb1368add..a6fc2fd19 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -65,12 +65,12 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5 + image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 restart: always database: container_name: immich_postgres - image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad + image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0 env_file: - .env environment: diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 442b0d4d5..2f9bff922 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -23,7 +23,7 @@ services: - database database: - image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad + image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0 command: -c fsync=off environment: POSTGRES_PASSWORD: postgres diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 16ca3a442..9155e5573 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -69,12 +69,12 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5 + image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 restart: always database: container_name: immich_postgres - image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad + image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0 env_file: - .env environment: diff --git a/docs/docs/install/docker-compose.md b/docs/docs/install/docker-compose.md index 6c1dc184b..58e13a95a 100644 --- a/docs/docs/install/docker-compose.md +++ b/docs/docs/install/docker-compose.md @@ -144,7 +144,7 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server" From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker-compose up -d`. ```bash title="Start the containers using docker compose command" -docker-compose up -d # or `docker compose up -d` based on your docker-compose version +docker compose up -d ``` :::tip @@ -162,7 +162,7 @@ If `IMMICH_VERSION` is set, it will need to be updated to the latest or desired When a new version of Immich is [released](https://github.com/immich-app/immich/releases), the application can be upgraded with the following commands, run in the directory with the `docker-compose.yml` file: ```bash title="Upgrade Immich" -docker-compose pull && docker-compose up -d # Or `docker compose up -d` +docker compose pull && docker compose up -d ``` :::caution Automatic Updates diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 8d7f400d9..40ee99dbe 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-bookworm@sha256:e5a1b0a194a5fbf94f6e350b31c9a508723f9eeb2f9e9e32c3b65df8520a40cc as builder +FROM python:3.11-bookworm@sha256:47c1829f72432c33609b3095259843a88c7ffc42cc9dbb55c43f2e7bbe46ca58 as builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}" COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --only main -FROM python:3.11-slim-bookworm@sha256:1bc6a3e9356d64ea632791653bc71a56340e8741dab66434ab2739ebf6aed29d +FROM python:3.11-slim-bookworm@sha256:8f82989e563d0dbad057a874a96438a360978c148e34f36c1db8d2d61b5fd6f0 RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/* diff --git a/renovate.json b/renovate.json index 12124c359..c81104d6b 100644 --- a/renovate.json +++ b/renovate.json @@ -52,7 +52,8 @@ }, { "groupName": "base-image", - "matchPackagePrefixes": ["ghcr.io/immich-app/base-server"] + "matchPackagePrefixes": ["ghcr.io/immich-app/base-server"], + "minimumReleaseAge": "0" }, { "matchDatasources": ["docker"], diff --git a/server/Dockerfile b/server/Dockerfile index bd269a378..c98383d02 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20231125@sha256:f33b6eaf384e76ef3705a6e2cc76d276144ad6d3366b82f9b45b07d6a19285e2 as dev +FROM ghcr.io/immich-app/base-server-dev:20231130@sha256:2f3b4bc0b50a0710e4a0867b4842ebde3a709d18fd19b095b8bfb884082cfa18 as dev WORKDIR /usr/src/app COPY server/package.json server/package-lock.json ./ @@ -23,7 +23,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20231125@sha256:a0e15f5bf87a97a79a399a5adffb5fe5befc18fb212e8341e744d958fe41e32a +FROM ghcr.io/immich-app/base-server-prod:20231130@sha256:dd91bfac4090357605a862823a99b50cf01cbc519723198f7aebb6b0517fab1d WORKDIR /usr/src/app ENV NODE_ENV=production @@ -37,6 +37,7 @@ COPY server/start*.sh ./ RUN npm link && npm cache clean --force COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE +ENV PATH="${PATH}:/usr/src/app/bin" VOLUME /usr/src/app/upload EXPOSE 3001 ENTRYPOINT ["tini", "--", "/bin/sh"] diff --git a/server/bin/admin-cli.sh b/server/bin/admin-cli.sh deleted file mode 100755 index b63e331eb..000000000 --- a/server/bin/admin-cli.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env sh -./start.sh admin-cli $1 diff --git a/server/bin/cli.sh b/server/bin/cli.sh deleted file mode 100755 index 3c6b1512e..000000000 --- a/server/bin/cli.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -npx immich "$@" diff --git a/server/bin/immich b/server/bin/immich new file mode 100755 index 000000000..053e87313 --- /dev/null +++ b/server/bin/immich @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +node /usr/src/app/node_modules/.bin/immich "$@" diff --git a/server/bin/immich-admin b/server/bin/immich-admin new file mode 100755 index 000000000..0634eae4b --- /dev/null +++ b/server/bin/immich-admin @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +/usr/src/app/start.sh immich-admin $1 diff --git a/server/package-lock.json b/server/package-lock.json index 77145427a..60367eb33 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -53,10 +53,6 @@ "typesense": "^1.7.1", "ua-parser-js": "^1.0.35" }, - "bin": { - "immich": "bin/cli.sh", - "immich-admin": "bin/admin-cli.sh" - }, "devDependencies": { "@nestjs/cli": "^10.1.16", "@nestjs/schematics": "^10.0.2", diff --git a/server/package.json b/server/package.json index bf424abe8..7c637215f 100644 --- a/server/package.json +++ b/server/package.json @@ -5,10 +5,6 @@ "author": "", "private": true, "license": "UNLICENSED", - "bin": { - "immich": "./bin/cli.sh", - "immich-admin": "./bin/admin-cli.sh" - }, "scripts": { "build": "nest build", "format": "prettier --check .", diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index c47b2acd2..862fafc32 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -1,6 +1,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthUserDto } from '../auth'; -import { setDifference, setUnion } from '../domain.util'; +import { setDifference, setIsEqual, setUnion } from '../domain.util'; import { IAccessRepository } from '../repositories'; export enum Permission { @@ -76,7 +76,7 @@ export class AccessCore { async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { ids = Array.isArray(ids) ? ids : [ids]; const allowedIds = await this.checkAccess(authUser, permission, ids); - if (new Set(ids).size !== allowedIds.size) { + if (!setIsEqual(new Set(ids), allowedIds)) { throw new BadRequestException(`Not found or no ${permission} access`); } } @@ -106,9 +106,24 @@ export class AccessCore { } switch (permission) { + case Permission.ASSET_READ: + return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); + + case Permission.ASSET_VIEW: + return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); + + case Permission.ASSET_DOWNLOAD: + return !!authUser.isAllowDownload + ? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids) + : new Set(); + case Permission.ASSET_UPLOAD: return authUser.isAllowUpload ? ids : new Set(); + case Permission.ASSET_SHARE: + // TODO: fix this to not use authUser.id for shared link access control + return await this.repository.asset.checkOwnerAccess(authUser.id, ids); + case Permission.ALBUM_READ: return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); @@ -116,46 +131,59 @@ export class AccessCore { return !!authUser.isAllowDownload ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); - } - - const allowedIds = new Set(); - for (const id of ids) { - const hasAccess = await this.hasSharedLinkAccess(authUser, permission, id); - if (hasAccess) { - allowedIds.add(id); - } - } - return allowedIds; - } - - // TODO: Migrate logic to checkAccessSharedLink to evaluate permissions in bulk. - private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) { - const sharedLinkId = authUser.sharedLinkId; - if (!sharedLinkId) { - return false; - } - - switch (permission) { - case Permission.ASSET_READ: - return this.repository.asset.hasSharedLinkAccess(sharedLinkId, id); - - case Permission.ASSET_VIEW: - return await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id); - - case Permission.ASSET_DOWNLOAD: - return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id)); - - case Permission.ASSET_SHARE: - // TODO: fix this to not use authUser.id for shared link access control - return this.repository.asset.hasOwnerAccess(authUser.id, id); default: - return false; + return new Set(); } } private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set) { switch (permission) { + case Permission.ASSET_READ: { + const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids); + const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner)); + const isPartner = await this.repository.asset.checkPartnerAccess( + authUser.id, + setDifference(ids, isOwner, isAlbum), + ); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_SHARE: { + const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids); + const isPartner = await this.repository.asset.checkPartnerAccess(authUser.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.ASSET_VIEW: { + const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids); + const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner)); + const isPartner = await this.repository.asset.checkPartnerAccess( + authUser.id, + setDifference(ids, isOwner, isAlbum), + ); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_DOWNLOAD: { + const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids); + const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner)); + const isPartner = await this.repository.asset.checkPartnerAccess( + authUser.id, + setDifference(ids, isOwner, isAlbum), + ); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_UPDATE: + return await this.repository.asset.checkOwnerAccess(authUser.id, ids); + + case Permission.ASSET_DELETE: + return await this.repository.asset.checkOwnerAccess(authUser.id, ids); + + case Permission.ASSET_RESTORE: + return await this.repository.asset.checkOwnerAccess(authUser.id, ids); + case Permission.ALBUM_READ: { const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids); const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner)); @@ -163,13 +191,13 @@ export class AccessCore { } case Permission.ALBUM_UPDATE: - return this.repository.album.checkOwnerAccess(authUser.id, ids); + return await this.repository.album.checkOwnerAccess(authUser.id, ids); case Permission.ALBUM_DELETE: - return this.repository.album.checkOwnerAccess(authUser.id, ids); + return await this.repository.album.checkOwnerAccess(authUser.id, ids); case Permission.ALBUM_SHARE: - return this.repository.album.checkOwnerAccess(authUser.id, ids); + return await this.repository.album.checkOwnerAccess(authUser.id, ids); case Permission.ALBUM_DOWNLOAD: { const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids); @@ -178,16 +206,16 @@ export class AccessCore { } case Permission.ALBUM_REMOVE_ASSET: - return this.repository.album.checkOwnerAccess(authUser.id, ids); + return await this.repository.album.checkOwnerAccess(authUser.id, ids); case Permission.ASSET_UPLOAD: - return this.repository.library.checkOwnerAccess(authUser.id, ids); + return await this.repository.library.checkOwnerAccess(authUser.id, ids); case Permission.ARCHIVE_READ: return ids.has(authUser.id) ? new Set([authUser.id]) : new Set(); case Permission.AUTH_DEVICE_DELETE: - return this.repository.authDevice.checkOwnerAccess(authUser.id, ids); + return await this.repository.authDevice.checkOwnerAccess(authUser.id, ids); case Permission.TIMELINE_READ: { const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set(); @@ -205,22 +233,22 @@ export class AccessCore { } case Permission.LIBRARY_UPDATE: - return this.repository.library.checkOwnerAccess(authUser.id, ids); + return await this.repository.library.checkOwnerAccess(authUser.id, ids); case Permission.LIBRARY_DELETE: - return this.repository.library.checkOwnerAccess(authUser.id, ids); + return await this.repository.library.checkOwnerAccess(authUser.id, ids); case Permission.PERSON_READ: - return this.repository.person.checkOwnerAccess(authUser.id, ids); + return await this.repository.person.checkOwnerAccess(authUser.id, ids); case Permission.PERSON_WRITE: - return this.repository.person.checkOwnerAccess(authUser.id, ids); + return await this.repository.person.checkOwnerAccess(authUser.id, ids); case Permission.PERSON_MERGE: - return this.repository.person.checkOwnerAccess(authUser.id, ids); + return await this.repository.person.checkOwnerAccess(authUser.id, ids); case Permission.PARTNER_UPDATE: - return this.repository.partner.checkUpdateAccess(authUser.id, ids); + return await this.repository.partner.checkUpdateAccess(authUser.id, ids); } const allowedIds = new Set(); @@ -247,41 +275,6 @@ export class AccessCore { (await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id)) ); - case Permission.ASSET_READ: - return ( - (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || - (await this.repository.asset.hasAlbumAccess(authUser.id, id)) || - (await this.repository.asset.hasPartnerAccess(authUser.id, id)) - ); - case Permission.ASSET_UPDATE: - return this.repository.asset.hasOwnerAccess(authUser.id, id); - - case Permission.ASSET_DELETE: - return this.repository.asset.hasOwnerAccess(authUser.id, id); - - case Permission.ASSET_RESTORE: - return this.repository.asset.hasOwnerAccess(authUser.id, id); - - case Permission.ASSET_SHARE: - return ( - (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || - (await this.repository.asset.hasPartnerAccess(authUser.id, id)) - ); - - case Permission.ASSET_VIEW: - return ( - (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || - (await this.repository.asset.hasAlbumAccess(authUser.id, id)) || - (await this.repository.asset.hasPartnerAccess(authUser.id, id)) - ); - - case Permission.ASSET_DOWNLOAD: - return ( - (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || - (await this.repository.asset.hasAlbumAccess(authUser.id, id)) || - (await this.repository.asset.hasPartnerAccess(authUser.id, id)) - ); - default: return false; } diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index e89030538..9a4614c79 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -509,7 +509,7 @@ describe(AlbumService.name, () => { describe('addAssets', () => { it('should allow the owner to add assets', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -534,7 +534,7 @@ describe(AlbumService.name, () => { it('should not set the thumbnail if the album has one already', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -552,7 +552,7 @@ describe(AlbumService.name, () => { it('should allow a shared user to add assets', async () => { accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -577,7 +577,7 @@ describe(AlbumService.name, () => { it('should allow a shared link user to add assets', async () => { accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -607,8 +607,7 @@ describe(AlbumService.name, () => { it('should allow adding assets shared via partner sharing', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); - accessMock.asset.hasPartnerAccess.mockResolvedValue(true); + accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -621,12 +620,12 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1'])); }); it('should skip duplicate assets', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); @@ -639,8 +638,6 @@ describe(AlbumService.name, () => { it('should skip assets not shared with user', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); - accessMock.asset.hasPartnerAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -648,8 +645,8 @@ describe(AlbumService.name, () => { { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); - expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1'])); }); it('should not allow unauthorized access to the album', async () => { diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 28a138254..e4052fb34 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -457,19 +457,15 @@ describe(AssetService.name, () => { describe('downloadFile', () => { it('should require the asset.download permission', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); - accessMock.asset.hasAlbumAccess.mockResolvedValue(false); - accessMock.asset.hasPartnerAccess.mockResolvedValue(false); - await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); - expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); - expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1'])); }); it('should throw an error if the asset is not found', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.getByIds.mockResolvedValue([]); await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); @@ -480,7 +476,7 @@ describe(AssetService.name, () => { it('should download a file', async () => { const stream = new Readable(); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.getByIds.mockResolvedValue([assetStub.image]); storageMock.createReadStream.mockResolvedValue({ stream }); @@ -496,7 +492,7 @@ describe(AssetService.name, () => { stream: new Readable(), }; - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noWebpPath]); storageMock.createZipStream.mockReturnValue(archiveMock); @@ -516,7 +512,7 @@ describe(AssetService.name, () => { stream: new Readable(), }; - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noResizePath]); storageMock.createZipStream.mockReturnValue(archiveMock); @@ -536,7 +532,7 @@ describe(AssetService.name, () => { }); it('should return a list of archives (assetIds)', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); const assetIds = ['asset-1', 'asset-2']; @@ -602,7 +598,9 @@ describe(AssetService.name, () => { }); it('should include the video portion of a live photo', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + const assetIds = [assetStub.livePhotoStillAsset.id]; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); when(assetMock.getByIds) .calledWith([assetStub.livePhotoStillAsset.id]) .mockResolvedValue([assetStub.livePhotoStillAsset]); @@ -610,7 +608,6 @@ describe(AssetService.name, () => { .calledWith([assetStub.livePhotoMotionAsset.id]) .mockResolvedValue([assetStub.livePhotoMotionAsset]); - const assetIds = [assetStub.livePhotoStillAsset.id]; await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ totalSize: 125_000, archives: [ @@ -651,7 +648,6 @@ describe(AssetService.name, () => { describe('update', () => { it('should require asset write access for the id', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( BadRequestException, ); @@ -659,14 +655,14 @@ describe(AssetService.name, () => { }); it('should update the asset', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.save.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); }); it('should update the exif description', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.save.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); @@ -675,7 +671,6 @@ describe(AssetService.name, () => { describe('updateAll', () => { it('should require asset write access for all ids', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.updateAll(authStub.admin, { ids: ['asset-1'], @@ -685,7 +680,7 @@ describe(AssetService.name, () => { }); it('should update all assets', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); @@ -693,8 +688,7 @@ describe(AssetService.name, () => { /// Stack related it('should require asset update access for parent', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect( sut.updateAll(authStub.user1, { ids: ['asset-1'], @@ -704,7 +698,7 @@ describe(AssetService.name, () => { }); it('should update parent asset when children are added', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent'])); await sut.updateAll(authStub.user1, { ids: [], stackParentId: 'parent', @@ -713,7 +707,7 @@ describe(AssetService.name, () => { }); it('should update parent asset when children are removed', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1'])); assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]); await sut.updateAll(authStub.user1, { @@ -724,7 +718,8 @@ describe(AssetService.name, () => { }); it('update parentId for new children', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2'])); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); await sut.updateAll(authStub.user1, { stackParentId: 'parent', ids: ['child-1', 'child-2'], @@ -734,7 +729,7 @@ describe(AssetService.name, () => { }); it('nullify parentId for remove children', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2'])); await sut.updateAll(authStub.user1, { removeParent: true, ids: ['child-1', 'child-2'], @@ -744,7 +739,8 @@ describe(AssetService.name, () => { }); it('merge stacks if new child has children', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); assetMock.getByIds.mockResolvedValue([ { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity, ]); @@ -758,7 +754,9 @@ describe(AssetService.name, () => { }); it('should send ws asset update event', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); + await sut.updateAll(authStub.user1, { ids: ['asset-1'], stackParentId: 'parent', @@ -772,7 +770,6 @@ describe(AssetService.name, () => { describe('deleteAll', () => { it('should require asset delete access for all ids', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { ids: ['asset-1'], @@ -781,7 +778,7 @@ describe(AssetService.name, () => { }); it('should force delete a batch of assets', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); @@ -792,7 +789,7 @@ describe(AssetService.name, () => { }); it('should soft delete a batch of assets', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); @@ -810,7 +807,6 @@ describe(AssetService.name, () => { describe('restoreAll', () => { it('should require asset restore access for all ids', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { ids: ['asset-1'], @@ -819,7 +815,7 @@ describe(AssetService.name, () => { }); it('should restore a batch of assets', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] }); @@ -984,19 +980,19 @@ describe(AssetService.name, () => { describe('run', () => { it('should run the refresh metadata job', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }), expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }); }); it('should run the refresh thumbnails job', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }), expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } }); }); it('should run the transcode video', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }), expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); }); @@ -1004,9 +1000,7 @@ describe(AssetService.name, () => { describe('updateStackParent', () => { it('should require asset update access for new parent', async () => { - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true); - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false); - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old'])); await expect( sut.updateStackParent(authStub.user1, { oldParentId: 'old', @@ -1016,8 +1010,7 @@ describe(AssetService.name, () => { }); it('should require asset read access for old parent', async () => { - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false); - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new'])); await expect( sut.updateStackParent(authStub.user1, { oldParentId: 'old', @@ -1027,7 +1020,9 @@ describe(AssetService.name, () => { }); it('make old parent the child of new parent', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id])); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); + when(assetMock.getById) .calledWith(assetStub.image.id) .mockResolvedValue(assetStub.image as AssetEntity); @@ -1041,7 +1036,9 @@ describe(AssetService.name, () => { }); it('remove stackParentId of new parent', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.primaryImage.id])); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); + await sut.updateStackParent(authStub.user1, { oldParentId: assetStub.primaryImage.id, newParentId: 'new', @@ -1051,7 +1048,8 @@ describe(AssetService.name, () => { }); it('update stackParentId of old parents children to new parent', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.primaryImage.id])); + accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); when(assetMock.getById) .calledWith(assetStub.primaryImage.id) .mockResolvedValue(assetStub.primaryImage as AssetEntity); diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index bacd4bfe6..72c257256 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -98,7 +98,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As tags: entity.tags?.map(mapTag), people: entity.faces ?.map(mapFace) - .filter((person): person is PersonResponseDto => person !== null && !person.isHidden) + .filter((person): person is PersonResponseDto => person !== null) .reduce((people, person) => { const existingPerson = people.find((p) => p.id === person.id); if (!existingPerson) { diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 00ad27bc7..1b1c0a3e2 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -155,18 +155,35 @@ export function Optional({ nullable, ...validationOptions }: OptionalOptions = { // They should be replaced with native Set operations, when they are added to the language. // Proposal reference: https://github.com/tc39/proposal-set-methods -export const setUnion = (setA: Set, setB: Set): Set => { - const union = new Set(setA); - for (const elem of setB) { - union.add(elem); +export const setUnion = (...sets: Set[]): Set => { + const union = new Set(sets[0]); + for (const set of sets.slice(1)) { + for (const elem of set) { + union.add(elem); + } } return union; }; -export const setDifference = (setA: Set, setB: Set): Set => { +export const setDifference = (setA: Set, ...sets: Set[]): Set => { const difference = new Set(setA); - for (const elem of setB) { - difference.delete(elem); + for (const set of sets) { + for (const elem of set) { + difference.delete(elem); + } } return difference; }; + +export const setIsSuperset = (set: Set, subset: Set): boolean => { + for (const elem of subset) { + if (!set.has(elem)) { + return false; + } + } + return true; +}; + +export const setIsEqual = (setA: Set, setB: Set): boolean => { + return setA.size === setB.size && setIsSuperset(setA, setB); +}; diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index c7e15e960..f49600a36 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -58,7 +58,8 @@ describe(LibraryService.name, () => { ctime: new Date('2023-01-01'), } as Stats); - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id])); + // Always validate owner access for library. + accessMock.library.checkOwnerAccess.mockImplementation(async (_, libraryIds) => libraryIds); sut = new LibraryService( accessMock, diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index b210a9165..44c20712b 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -331,7 +331,7 @@ describe(PersonService.name, () => { personMock.getById.mockResolvedValue(personStub.withName); personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 7736fd890..b6a71daf8 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -6,11 +6,12 @@ export interface IAccessRepository { hasAlbumOwnerAccess(userId: string, activityId: string): Promise; hasCreateAccess(userId: string, albumId: string): Promise; }; + asset: { - hasOwnerAccess(userId: string, assetId: string): Promise; - hasAlbumAccess(userId: string, assetId: string): Promise; - hasPartnerAccess(userId: string, assetId: string): Promise; - hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise; + checkOwnerAccess(userId: string, assetIds: Set): Promise>; + checkAlbumAccess(userId: string, assetIds: Set): Promise>; + checkPartnerAccess(userId: string, assetIds: Set): Promise>; + checkSharedLinkAccess(sharedLinkId: string, assetIds: Set): Promise>; }; authDevice: { diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts index abf8128c4..bfc74e824 100644 --- a/server/src/domain/shared-link/shared-link.service.spec.ts +++ b/server/src/domain/shared-link/shared-link.service.spec.ts @@ -11,7 +11,6 @@ import { sharedLinkResponseStub, sharedLinkStub, } from '@test'; -import { when } from 'jest-when'; import _ from 'lodash'; import { AssetIdErrorReason } from '../asset'; import { ICryptoRepository, ISharedLinkRepository } from '../repositories'; @@ -109,7 +108,6 @@ describe(SharedLinkService.name, () => { }); it('should require asset ownership to make an individual shared link', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }), ).rejects.toBeInstanceOf(BadRequestException); @@ -140,7 +138,7 @@ describe(SharedLinkService.name, () => { }); it('should create an individual shared link', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); shareMock.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { @@ -151,7 +149,7 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set([assetStub.image.id])); expect(shareMock.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.id, @@ -215,9 +213,7 @@ describe(SharedLinkService.name, () => { it('should add assets to a shared link', async () => { shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); shareMock.create.mockResolvedValue(sharedLinkStub.individual); - - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false); - when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); await expect( sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), @@ -227,7 +223,7 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-3', success: true }, ]); - expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [assetStub.image, { id: 'asset-3' }], diff --git a/server/src/admin-cli/app.module.ts b/server/src/immich-admin/app.module.ts similarity index 100% rename from server/src/admin-cli/app.module.ts rename to server/src/immich-admin/app.module.ts diff --git a/server/src/admin-cli/commands/list-users.command.ts b/server/src/immich-admin/commands/list-users.command.ts similarity index 100% rename from server/src/admin-cli/commands/list-users.command.ts rename to server/src/immich-admin/commands/list-users.command.ts diff --git a/server/src/admin-cli/commands/password-login.ts b/server/src/immich-admin/commands/password-login.ts similarity index 100% rename from server/src/admin-cli/commands/password-login.ts rename to server/src/immich-admin/commands/password-login.ts diff --git a/server/src/admin-cli/commands/reset-admin-password.command.ts b/server/src/immich-admin/commands/reset-admin-password.command.ts similarity index 100% rename from server/src/admin-cli/commands/reset-admin-password.command.ts rename to server/src/immich-admin/commands/reset-admin-password.command.ts diff --git a/server/src/admin-cli/constants.ts b/server/src/immich-admin/constants.ts similarity index 100% rename from server/src/admin-cli/constants.ts rename to server/src/immich-admin/constants.ts diff --git a/server/src/admin-cli/main.ts b/server/src/immich-admin/main.ts similarity index 100% rename from server/src/admin-cli/main.ts rename to server/src/immich-admin/main.ts diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 11173b55f..9b1e02b9c 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -232,54 +232,49 @@ describe('AssetService', () => { describe('getAssetById', () => { it('should allow owner access', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); assetRepositoryMock.getById.mockResolvedValue(assetStub.image); await sut.getAssetById(authStub.admin, assetStub.image.id); - expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set([assetStub.image.id])); }); it('should allow shared link access', async () => { - accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true); + accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); assetRepositoryMock.getById.mockResolvedValue(assetStub.image); await sut.getAssetById(authStub.adminSharedLink, assetStub.image.id); - expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith( + expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLinkId, - assetStub.image.id, + new Set([assetStub.image.id]), ); }); it('should allow partner sharing access', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); - accessMock.asset.hasPartnerAccess.mockResolvedValue(true); + accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); assetRepositoryMock.getById.mockResolvedValue(assetStub.image); await sut.getAssetById(authStub.admin, assetStub.image.id); - expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith( + authStub.admin.id, + new Set([assetStub.image.id]), + ); }); it('should allow shared album access', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); - accessMock.asset.hasPartnerAccess.mockResolvedValue(false); - accessMock.asset.hasAlbumAccess.mockResolvedValue(true); + accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); assetRepositoryMock.getById.mockResolvedValue(assetStub.image); await sut.getAssetById(authStub.admin, assetStub.image.id); - expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set([assetStub.image.id])); }); it('should throw an error for no access', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(false); - accessMock.asset.hasPartnerAccess.mockResolvedValue(false); - accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false); - accessMock.asset.hasAlbumAccess.mockResolvedValue(false); await expect(sut.getAssetById(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); expect(assetRepositoryMock.getById).not.toHaveBeenCalled(); }); it('should throw an error for an invalid shared link', async () => { - accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false); await expect(sut.getAssetById(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled(); + expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(assetRepositoryMock.getById).not.toHaveBeenCalled(); }); }); diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index b23c559a6..208b7095c 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -1,6 +1,6 @@ import { IAccessRepository } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { Brackets, In, Repository } from 'typeorm'; import { ActivityEntity, AlbumEntity, @@ -112,107 +112,120 @@ export class AccessRepository implements IAccessRepository { }; asset = { - hasAlbumAccess: (userId: string, assetId: string): Promise => { - return this.albumRepository.exist({ - where: [ - { + checkAlbumAccess: async (userId: string, assetIds: Set): Promise> => { + if (assetIds.size === 0) { + return new Set(); + } + + return this.albumRepository + .createQueryBuilder('album') + .innerJoin('album.assets', 'asset') + .leftJoin('album.sharedUsers', 'sharedUsers') + .select('asset.id', 'assetId') + .addSelect('asset.livePhotoVideoId', 'livePhotoVideoId') + .where( + new Brackets((qb) => { + qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId }); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where('asset.id IN (:...assetIds)', { assetIds: [...assetIds] }) + // still part of a live photo is in an album + .orWhere('asset.livePhotoVideoId IN (:...assetIds)', { assetIds: [...assetIds] }); + }), + ) + .getRawMany() + .then((rows) => { + const allowedIds = new Set(); + for (const row of rows) { + if (row.assetId && assetIds.has(row.assetId)) { + allowedIds.add(row.assetId); + } + if (row.livePhotoVideoId && assetIds.has(row.livePhotoVideoId)) { + allowedIds.add(row.livePhotoVideoId); + } + } + return allowedIds; + }); + }, + + checkOwnerAccess: async (userId: string, assetIds: Set): Promise> => { + if (assetIds.size === 0) { + return new Set(); + } + + return this.assetRepository + .find({ + select: { id: true }, + where: { + id: In([...assetIds]), ownerId: userId, - assets: { - id: assetId, - }, }, - { - sharedUsers: { - id: userId, - }, - assets: { - id: assetId, - }, - }, - // still part of a live photo is in an album - { - ownerId: userId, - assets: { - livePhotoVideoId: assetId, - }, - }, - { - sharedUsers: { - id: userId, - }, - assets: { - livePhotoVideoId: assetId, - }, - }, - ], - }); + withDeleted: true, + }) + .then((assets) => new Set(assets.map((asset) => asset.id))); }, - hasOwnerAccess: (userId: string, assetId: string): Promise => { - return this.assetRepository.exist({ - where: { - id: assetId, - ownerId: userId, - }, - withDeleted: true, - }); + checkPartnerAccess: async (userId: string, assetIds: Set): Promise> => { + if (assetIds.size === 0) { + return new Set(); + } + + return this.partnerRepository + .createQueryBuilder('partner') + .innerJoin('partner.sharedBy', 'sharedBy') + .innerJoin('sharedBy.assets', 'asset') + .select('asset.id', 'assetId') + .where('partner.sharedWithId = :userId', { userId }) + .andWhere('asset.id IN (:...assetIds)', { assetIds: [...assetIds] }) + .getRawMany() + .then((rows) => new Set(rows.map((row) => row.assetId))); }, - hasPartnerAccess: (userId: string, assetId: string): Promise => { - return this.partnerRepository.exist({ - where: { - sharedWith: { - id: userId, - }, - sharedBy: { - assets: { - id: assetId, - }, - }, - }, - relations: { - sharedWith: true, - sharedBy: { - assets: true, - }, - }, - }); - }, + checkSharedLinkAccess: async (sharedLinkId: string, assetIds: Set): Promise> => { + if (assetIds.size === 0) { + return new Set(); + } - hasSharedLinkAccess: async (sharedLinkId: string, assetId: string): Promise => { - return this.sharedLinkRepository.exist({ - where: [ - { - id: sharedLinkId, - album: { - assets: { - id: assetId, - }, - }, - }, - { - id: sharedLinkId, - assets: { - id: assetId, - }, - }, - // still part of a live photo is in a shared link - { - id: sharedLinkId, - album: { - assets: { - livePhotoVideoId: assetId, - }, - }, - }, - { - id: sharedLinkId, - assets: { - livePhotoVideoId: assetId, - }, - }, - ], - }); + return this.sharedLinkRepository + .createQueryBuilder('sharedLink') + .leftJoin('sharedLink.album', 'album') + .leftJoin('sharedLink.assets', 'assets') + .leftJoin('album.assets', 'albumAssets') + .select('assets.id', 'assetId') + .addSelect('albumAssets.id', 'albumAssetId') + .addSelect('assets.livePhotoVideoId', 'assetLivePhotoVideoId') + .addSelect('albumAssets.livePhotoVideoId', 'albumAssetLivePhotoVideoId') + .where('sharedLink.id = :sharedLinkId', { sharedLinkId }) + .andWhere( + new Brackets((qb) => { + qb.where('assets.id IN (:...assetIds)', { assetIds: [...assetIds] }) + .orWhere('albumAssets.id IN (:...assetIds)', { assetIds: [...assetIds] }) + // still part of a live photo is in a shared link + .orWhere('assets.livePhotoVideoId IN (:...assetIds)', { assetIds: [...assetIds] }) + .orWhere('albumAssets.livePhotoVideoId IN (:...assetIds)', { assetIds: [...assetIds] }); + }), + ) + .getRawMany() + .then((rows) => { + const allowedIds = new Set(); + for (const row of rows) { + if (row.assetId && assetIds.has(row.assetId)) { + allowedIds.add(row.assetId); + } + if (row.assetLivePhotoVideoId && assetIds.has(row.assetLivePhotoVideoId)) { + allowedIds.add(row.assetLivePhotoVideoId); + } + if (row.albumAssetId && assetIds.has(row.albumAssetId)) { + allowedIds.add(row.albumAssetId); + } + if (row.albumAssetLivePhotoVideoId && assetIds.has(row.albumAssetLivePhotoVideoId)) { + allowedIds.add(row.albumAssetLivePhotoVideoId); + } + } + return allowedIds; + }); }, }; diff --git a/server/src/main.ts b/server/src/main.ts index b86f4f789..c43d6ea46 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,5 +1,5 @@ -import { bootstrap as adminCli } from './admin-cli/main'; -import { bootstrap as immich } from './immich/main'; +import { bootstrap as admin } from './immich-admin/main'; +import { bootstrap as server } from './immich/main'; import { bootstrap as microservices } from './microservices/main'; const immichApp = process.argv[2] || process.env.IMMICH_APP; @@ -12,13 +12,13 @@ function bootstrap() { switch (immichApp) { case 'immich': process.title = 'immich_server'; - return immich(); + return server(); case 'microservices': process.title = 'immich_microservices'; return microservices(); - case 'admin-cli': + case 'immich-admin': process.title = 'immich_admin_cli'; - return adminCli(); + return admin(); default: console.log(`Invalid app name: ${immichApp}. Expected one of immich|microservices|cli`); process.exit(1); diff --git a/server/start.sh b/server/start.sh index 5ec1a2643..7aa0bc20d 100755 --- a/server/start.sh +++ b/server/start.sh @@ -32,4 +32,4 @@ if [ "$REDIS_PASSWORD_FILE" ]; then unset REDIS_PASSWORD_FILE fi -exec node dist/main $@ +exec node /usr/src/app/dist/main $@ diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index f495d800e..4c2a5ed8d 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -22,11 +22,12 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => hasAlbumOwnerAccess: jest.fn(), hasCreateAccess: jest.fn(), }, + asset: { - hasOwnerAccess: jest.fn(), - hasAlbumAccess: jest.fn(), - hasPartnerAccess: jest.fn(), - hasSharedLinkAccess: jest.fn(), + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), + checkAlbumAccess: jest.fn().mockResolvedValue(new Set()), + checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), + checkSharedLinkAccess: jest.fn().mockResolvedValue(new Set()), }, album: { diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index ba71c396d..609a03e25 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -144,7 +144,7 @@
- +

{ - if (album && user) { + if (album && $user) { try { const { data } = await api.activityApi.getActivities({ - userId: user.id, + userId: $user.id, assetId: asset.id, albumId: album.id, type: ReactionType.Like, @@ -753,7 +752,7 @@ {/if} - {#if isShared && album && isShowActivity && user} + {#if isShared && album && isShowActivity && $user}

{ if (assetUpdate && assetUpdate.id === asset.id) { @@ -176,25 +181,38 @@ {#if !api.isSharedLink && people.length > 0}
-

PEOPLE

+
+

PEOPLE

+ {#if people.some((person) => person.isHidden)} + (showingHiddenPeople = !showingHiddenPeople)} + /> + {/if} +
-
+ - {:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly} + {:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly && $user && asset.ownerId === $user.id}
@@ -410,12 +433,14 @@ {#if asset.exifInfo?.city && !asset.isReadOnly}
(isShowChangeLocation = true)} - on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + class="flex justify-between place-items-start gap-4 py-4" + on:click={() => (isOwner ? (isShowChangeLocation = true) : null)} + on:keydown={(event) => (isOwner ? event.key === 'Enter' && (isShowChangeLocation = true) : null)} tabindex="0" + title={isOwner ? 'Edit location' : ''} role="button" - title="Edit location" + class:hover:dark:text-immich-dark-primary={isOwner} + class:hover:text-immich-primary={isOwner} >
@@ -435,11 +460,13 @@
-
- -
+ {#if isOwner} +
+ +
+ {/if}
- {:else if !asset.exifInfo?.city && !asset.isReadOnly} + {:else if !asset.exifInfo?.city && !asset.isReadOnly && $user && asset.ownerId === $user.id}
(isShowChangeLocation = true)} diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte index b917a36fe..8f9748261 100644 --- a/web/src/lib/components/photos-page/actions/change-date-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte @@ -5,6 +5,8 @@ import { DateTime } from 'luxon'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte'; + import { user } from '$lib/stores/user.store'; + import { getSelectedAssets } from '$lib/utils/asset-utils'; export let menuItem = false; const { clearSelect, getOwnedAssets } = getAssetControlContext(); @@ -12,9 +14,7 @@ const handleConfirm = async (dateTimeOriginal: string) => { isShowChangeDate = false; - const ids = Array.from(getOwnedAssets()) - .filter((a) => !a.isExternal) - .map((a) => a.id); + const ids = getSelectedAssets(getOwnedAssets(), $user); try { await api.assetApi.updateAssets({ diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte index b916d26c3..ac1e66d09 100644 --- a/web/src/lib/components/photos-page/actions/change-location-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte @@ -4,6 +4,8 @@ import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; import { handleError } from '../../../utils/handle-error'; + import { user } from '$lib/stores/user.store'; + import { getSelectedAssets } from '$lib/utils/asset-utils'; export let menuItem = false; const { clearSelect, getOwnedAssets } = getAssetControlContext(); @@ -12,9 +14,7 @@ async function handleConfirm(point: { lng: number; lat: number }) { isShowChangeLocation = false; - const ids = Array.from(getOwnedAssets()) - .filter((a) => !a.isExternal) - .map((a) => a.id); + const ids = getSelectedAssets(getOwnedAssets(), $user); try { await api.assetApi.updateAssets({ diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 1e5304e8e..310b2d4de 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -8,7 +8,7 @@ import { locale } from '$lib/stores/preferences.store'; import { isSearchEnabled } from '$lib/stores/search.store'; import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util'; - import type { AlbumResponseDto, AssetResponseDto, UserResponseDto } from '@api'; + import type { AlbumResponseDto, AssetResponseDto } from '@api'; import { DateTime } from 'luxon'; import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import AssetViewer from '../asset-viewer/asset-viewer.svelte'; @@ -27,7 +27,6 @@ export let removeAction: AssetAction | null = null; export let withStacked = false; export let isShared = false; - export let user: UserResponseDto | null = null; export let album: AlbumResponseDto | null = null; $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; @@ -394,7 +393,6 @@ {#if $showAssetViewer} = new Set(); @@ -103,6 +102,6 @@ {/if}
- +
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index dc62bc8da..0aa51dfa9 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -2,7 +2,7 @@ import { page } from '$app/stores'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { handleError } from '$lib/utils/handle-error'; - import { AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api'; + import { AssetResponseDto, ThumbnailFormat } from '@api'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import { flip } from 'svelte/animate'; import { getThumbnailSize } from '$lib/utils/thumbnail-util'; @@ -13,7 +13,6 @@ export let selectedAssets: Set = new Set(); export let disableAssetSelect = false; export let showArchiveIcon = false; - export let user: UserResponseDto | undefined = undefined; let { isViewing: showAssetViewer } = assetViewingStore; @@ -109,7 +108,6 @@ {#if $showAssetViewer} { await refresh(); @@ -23,7 +22,7 @@ const refresh = async () => { try { const { data } = await api.serverInfoApi.getServerInfo(); - serverInfo = data; + $serverInfoStore = data; } catch (e) { console.log('Error [StatusBox] [onMount]'); } @@ -44,7 +43,7 @@
- {#if album.sharedUsers.length > 0 && album && isShowActivity && user && !$showAssetViewer} + {#if album.sharedUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
{/if} -{#if viewMode === ViewMode.OPTIONS} +{#if viewMode === ViewMode.OPTIONS && $user} (viewMode = ViewMode.VIEW)} on:toggleEnableActivity={handleToggleEnableActivity} on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index e74a109b9..6cf3bdd64 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -24,6 +24,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import UpdatePanel from '$lib/components/shared-components/update-panel.svelte'; + import { user } from '$lib/stores/user.store'; export let data: PageData; @@ -33,6 +34,8 @@ const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; + $user = data.user; + $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); const handleEscape = () => {