Merge branch 'main' into feat/rotate-photo

This commit is contained in:
martabal 2023-12-02 16:20:01 +01:00
commit 618a6cd524
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
51 changed files with 459 additions and 401 deletions

2
.gitattributes vendored
View file

@ -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/**/*.md linguist-generated=true
web/src/api/open-api/**/*.ts -diff -merge web/src/api/open-api/**/*.ts -diff -merge
web/src/api/open-api/**/*.ts linguist-generated=true web/src/api/open-api/**/*.ts linguist-generated=true
*.sh text eol=lf

View file

@ -11,7 +11,7 @@
<p align="center"> <p align="center">
<img src="design/immich-logo.svg" width="150" title="Login mit eigener URL"> <img src="design/immich-logo.svg" width="150" title="Login mit eigener URL">
</p> </p>
<h3 align="center">Immich - Hoch performante, selbst gehostete Backup Lösung für Fotos und Videos</h3> <h3 align="center">Immich - Hoch performante, selbst gehostete Backup-Lösung für Fotos und Videos</h3>
<br/> <br/>
<a href="https://immich.app"> <a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Haupt-Screenshot"> <img src="design/immich-screenshots.png" title="Haupt-Screenshot">
@ -32,10 +32,10 @@
## Warnung ## Warnung
- ⚠️ Das Projekt befindet sich unter **sehr aktiver** Entwicklung. - ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
- ⚠️ Erwarte Fehler und Änderungen mit Breaking-Changes. - ⚠️ Erwarte Fehler und Änderungen mit Breaking-Changes.
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.** - ⚠️ **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 ## Inhalt
@ -54,9 +54,9 @@ Die Hauptdokumentation, inklusive Installationsanleitungen, ist unter https://im
## Demo ## 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" ```bash title="Demo Credential"
Die Anmeldedaten Die Anmeldedaten
@ -73,8 +73,8 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Funktionen | Mobil | Web | | Funktionen | Mobil | Web |
| ---------------------------------------------------- | ------ | ----- | | ---------------------------------------------------- | ------ | ----- |
| Fotos & Videos hochladen und ansehen | Ja | Ja | | Fotos & Videos hochladen und ansehen | Ja | Ja |
| Automatisches Backup wenn die App offen ist | Ja | n. a. | | Automatisches Backup wenn die App geöffnet ist | Ja | n. a. |
| Selektive Auswahl von Alben zum Backup | Ja | n. a. | | Selektive Auswahl von Alben zum Sichern | Ja | n. a. |
| Fotos und Videos auf das Gerät herunterladen | Ja | Ja | | Fotos und Videos auf das Gerät herunterladen | Ja | Ja |
| Unterstützt mehrere Benutzer | Ja | Ja | | Unterstützt mehrere Benutzer | Ja | Ja |
| Album und geteilte Alben | 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 | | Unterstützt RAW Formate | Ja | Ja |
| Metadaten anzeigen (EXIF, Karte) | Ja | Ja | | Metadaten anzeigen (EXIF, Karte) | Ja | Ja |
| Suchen nach Metadaten, Objekten, Gesichtern und CLIP | 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. | | Backup im Hintergrund | Ja | n. a. |
| Virtuelles Scrollen | Ja | Ja | | Virtuelles Scrollen | Ja | Ja |
| OAuth Unterstützung | 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 ## 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. 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 ### Spenden
- [Monatliche Spende](https://github.com/sponsors/alextran1502) via GitHub Sponsors - [Monatliche Spende](https://github.com/sponsors/immich-app) via GitHub Sponsors
- [Einmalige Spende](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) 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/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz - ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
## Unterstützer ## Mitwirkende
<a href="https://github.com/alextran1502/immich/graphs/contributors"> <a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/> <img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a> </a>

12
cli/package-lock.json generated
View file

@ -1604,9 +1604,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.10.0", "version": "20.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@ -7990,9 +7990,9 @@
} }
}, },
"@types/node": { "@types/node": {
"version": "20.10.0", "version": "20.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==",
"dev": true, "dev": true,
"requires": { "requires": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"

View file

@ -108,11 +108,11 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5 image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
database: database:
container_name: immich_postgres container_name: immich_postgres
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -65,12 +65,12 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5 image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
restart: always restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -23,7 +23,7 @@ services:
- database - database
database: database:
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0
command: -c fsync=off command: -c fsync=off
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

View file

@ -69,12 +69,12 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5 image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
restart: always restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad image: postgres:14-alpine@sha256:54916702f83d9330d355e078f69a7ba42f2cf2e530c8fe63423d6680d8da45b0
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -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`. 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" ```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 :::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: 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" ```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 :::caution Automatic Updates

View file

@ -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 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main 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/* RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*

View file

@ -52,7 +52,8 @@
}, },
{ {
"groupName": "base-image", "groupName": "base-image",
"matchPackagePrefixes": ["ghcr.io/immich-app/base-server"] "matchPackagePrefixes": ["ghcr.io/immich-app/base-server"],
"minimumReleaseAge": "0"
}, },
{ {
"matchDatasources": ["docker"], "matchDatasources": ["docker"],

View file

@ -1,5 +1,5 @@
# dev build # 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 WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
@ -23,7 +23,7 @@ RUN npm run build
# prod 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 WORKDIR /usr/src/app
ENV NODE_ENV=production ENV NODE_ENV=production
@ -37,6 +37,7 @@ COPY server/start*.sh ./
RUN npm link && npm cache clean --force RUN npm link && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/bin"
VOLUME /usr/src/app/upload VOLUME /usr/src/app/upload
EXPOSE 3001 EXPOSE 3001
ENTRYPOINT ["tini", "--", "/bin/sh"] ENTRYPOINT ["tini", "--", "/bin/sh"]

View file

@ -1,2 +0,0 @@
#!/usr/bin/env sh
./start.sh admin-cli $1

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
npx immich "$@"

2
server/bin/immich Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
node /usr/src/app/node_modules/.bin/immich "$@"

2
server/bin/immich-admin Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env sh
/usr/src/app/start.sh immich-admin $1

View file

@ -53,10 +53,6 @@
"typesense": "^1.7.1", "typesense": "^1.7.1",
"ua-parser-js": "^1.0.35" "ua-parser-js": "^1.0.35"
}, },
"bin": {
"immich": "bin/cli.sh",
"immich-admin": "bin/admin-cli.sh"
},
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.1.16", "@nestjs/cli": "^10.1.16",
"@nestjs/schematics": "^10.0.2", "@nestjs/schematics": "^10.0.2",

View file

@ -5,10 +5,6 @@
"author": "", "author": "",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"bin": {
"immich": "./bin/cli.sh",
"immich-admin": "./bin/admin-cli.sh"
},
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --check .", "format": "prettier --check .",

View file

@ -1,6 +1,6 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { setDifference, setUnion } from '../domain.util'; import { setDifference, setIsEqual, setUnion } from '../domain.util';
import { IAccessRepository } from '../repositories'; import { IAccessRepository } from '../repositories';
export enum Permission { export enum Permission {
@ -76,7 +76,7 @@ export class AccessCore {
async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
ids = Array.isArray(ids) ? ids : [ids]; ids = Array.isArray(ids) ? ids : [ids];
const allowedIds = await this.checkAccess(authUser, permission, 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`); throw new BadRequestException(`Not found or no ${permission} access`);
} }
} }
@ -106,9 +106,24 @@ export class AccessCore {
} }
switch (permission) { 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: case Permission.ASSET_UPLOAD:
return authUser.isAllowUpload ? ids : new Set(); 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: case Permission.ALBUM_READ:
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
@ -116,46 +131,59 @@ export class AccessCore {
return !!authUser.isAllowDownload return !!authUser.isAllowDownload
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
: new Set(); : 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: default:
return false; return new Set();
} }
} }
private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set<string>) { private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
switch (permission) { 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: { case Permission.ALBUM_READ: {
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids); const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner)); const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
@ -163,13 +191,13 @@ export class AccessCore {
} }
case Permission.ALBUM_UPDATE: 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: 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: 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: { case Permission.ALBUM_DOWNLOAD: {
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids); const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
@ -178,16 +206,16 @@ export class AccessCore {
} }
case Permission.ALBUM_REMOVE_ASSET: 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: 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: case Permission.ARCHIVE_READ:
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set(); return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
case Permission.AUTH_DEVICE_DELETE: 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: { case Permission.TIMELINE_READ: {
const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set<string>(); const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set<string>();
@ -205,22 +233,22 @@ export class AccessCore {
} }
case Permission.LIBRARY_UPDATE: 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: 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: 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: 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: 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: 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(); const allowedIds = new Set();
@ -247,41 +275,6 @@ export class AccessCore {
(await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id)) (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: default:
return false; return false;
} }

View file

@ -509,7 +509,7 @@ describe(AlbumService.name, () => {
describe('addAssets', () => { describe('addAssets', () => {
it('should allow the owner to add assets', async () => { it('should allow the owner to add assets', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); 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.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValueOnce(new Set()); 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 () => { it('should not set the thumbnail if the album has one already', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); 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.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
albumMock.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
@ -552,7 +552,7 @@ describe(AlbumService.name, () => {
it('should allow a shared user to add assets', async () => { it('should allow a shared user to add assets', async () => {
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); 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.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
albumMock.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
@ -577,7 +577,7 @@ describe(AlbumService.name, () => {
it('should allow a shared link user to add assets', async () => { it('should allow a shared link user to add assets', async () => {
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); 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.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
@ -607,8 +607,7 @@ describe(AlbumService.name, () => {
it('should allow adding assets shared via partner sharing', async () => { it('should allow adding assets shared via partner sharing', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
@ -621,12 +620,12 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', 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 () => { it('should skip duplicate assets', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); 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.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
@ -639,8 +638,6 @@ describe(AlbumService.name, () => {
it('should skip assets not shared with user', async () => { it('should skip assets not shared with user', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
albumMock.getAssetIds.mockResolvedValueOnce(new Set()); albumMock.getAssetIds.mockResolvedValueOnce(new Set());
@ -648,8 +645,8 @@ describe(AlbumService.name, () => {
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
]); ]);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['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 not allow unauthorized access to the album', async () => { it('should not allow unauthorized access to the album', async () => {

View file

@ -457,19 +457,15 @@ describe(AssetService.name, () => {
describe('downloadFile', () => { describe('downloadFile', () => {
it('should require the asset.download permission', async () => { 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); await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['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 throw an error if the asset is not found', async () => { 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([]); assetMock.getByIds.mockResolvedValue([]);
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
@ -480,7 +476,7 @@ describe(AssetService.name, () => {
it('should download a file', async () => { it('should download a file', async () => {
const stream = new Readable(); const stream = new Readable();
accessMock.asset.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
storageMock.createReadStream.mockResolvedValue({ stream }); storageMock.createReadStream.mockResolvedValue({ stream });
@ -496,7 +492,7 @@ describe(AssetService.name, () => {
stream: new Readable(), 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]); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noWebpPath]);
storageMock.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
@ -516,7 +512,7 @@ describe(AssetService.name, () => {
stream: new Readable(), 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]); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noResizePath]);
storageMock.createZipStream.mockReturnValue(archiveMock); storageMock.createZipStream.mockReturnValue(archiveMock);
@ -536,7 +532,7 @@ describe(AssetService.name, () => {
}); });
it('should return a list of archives (assetIds)', async () => { 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]); assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]);
const assetIds = ['asset-1', 'asset-2']; const assetIds = ['asset-1', 'asset-2'];
@ -602,7 +598,9 @@ describe(AssetService.name, () => {
}); });
it('should include the video portion of a live photo', async () => { 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) when(assetMock.getByIds)
.calledWith([assetStub.livePhotoStillAsset.id]) .calledWith([assetStub.livePhotoStillAsset.id])
.mockResolvedValue([assetStub.livePhotoStillAsset]); .mockResolvedValue([assetStub.livePhotoStillAsset]);
@ -610,7 +608,6 @@ describe(AssetService.name, () => {
.calledWith([assetStub.livePhotoMotionAsset.id]) .calledWith([assetStub.livePhotoMotionAsset.id])
.mockResolvedValue([assetStub.livePhotoMotionAsset]); .mockResolvedValue([assetStub.livePhotoMotionAsset]);
const assetIds = [assetStub.livePhotoStillAsset.id];
await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
totalSize: 125_000, totalSize: 125_000,
archives: [ archives: [
@ -651,7 +648,6 @@ describe(AssetService.name, () => {
describe('update', () => { describe('update', () => {
it('should require asset write access for the id', async () => { 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( await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@ -659,14 +655,14 @@ describe(AssetService.name, () => {
}); });
it('should update the asset', async () => { it('should update the asset', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
assetMock.save.mockResolvedValue(assetStub.image); assetMock.save.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
}); });
it('should update the exif description', async () => { 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); assetMock.save.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
@ -675,7 +671,6 @@ describe(AssetService.name, () => {
describe('updateAll', () => { describe('updateAll', () => {
it('should require asset write access for all ids', async () => { it('should require asset write access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.updateAll(authStub.admin, { sut.updateAll(authStub.admin, {
ids: ['asset-1'], ids: ['asset-1'],
@ -685,7 +680,7 @@ describe(AssetService.name, () => {
}); });
it('should update all assets', async () => { 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 }); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['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 /// Stack related
it('should require asset update access for parent', async () => { it('should require asset update access for parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
await expect( await expect(
sut.updateAll(authStub.user1, { sut.updateAll(authStub.user1, {
ids: ['asset-1'], ids: ['asset-1'],
@ -704,7 +698,7 @@ describe(AssetService.name, () => {
}); });
it('should update parent asset when children are added', async () => { 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, { await sut.updateAll(authStub.user1, {
ids: [], ids: [],
stackParentId: 'parent', stackParentId: 'parent',
@ -713,7 +707,7 @@ describe(AssetService.name, () => {
}); });
it('should update parent asset when children are removed', async () => { 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]); assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
await sut.updateAll(authStub.user1, { await sut.updateAll(authStub.user1, {
@ -724,7 +718,8 @@ describe(AssetService.name, () => {
}); });
it('update parentId for new children', async () => { 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, { await sut.updateAll(authStub.user1, {
stackParentId: 'parent', stackParentId: 'parent',
ids: ['child-1', 'child-2'], ids: ['child-1', 'child-2'],
@ -734,7 +729,7 @@ describe(AssetService.name, () => {
}); });
it('nullify parentId for remove children', async () => { 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, { await sut.updateAll(authStub.user1, {
removeParent: true, removeParent: true,
ids: ['child-1', 'child-2'], ids: ['child-1', 'child-2'],
@ -744,7 +739,8 @@ describe(AssetService.name, () => {
}); });
it('merge stacks if new child has children', async () => { 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([ assetMock.getByIds.mockResolvedValue([
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity, { 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 () => { 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, { await sut.updateAll(authStub.user1, {
ids: ['asset-1'], ids: ['asset-1'],
stackParentId: 'parent', stackParentId: 'parent',
@ -772,7 +770,6 @@ describe(AssetService.name, () => {
describe('deleteAll', () => { describe('deleteAll', () => {
it('should require asset delete access for all ids', async () => { it('should require asset delete access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.deleteAll(authStub.user1, { sut.deleteAll(authStub.user1, {
ids: ['asset-1'], ids: ['asset-1'],
@ -781,7 +778,7 @@ describe(AssetService.name, () => {
}); });
it('should force delete a batch of assets', async () => { 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 }); 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 () => { 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 }); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
@ -810,7 +807,6 @@ describe(AssetService.name, () => {
describe('restoreAll', () => { describe('restoreAll', () => {
it('should require asset restore access for all ids', async () => { it('should require asset restore access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.deleteAll(authStub.user1, { sut.deleteAll(authStub.user1, {
ids: ['asset-1'], ids: ['asset-1'],
@ -819,7 +815,7 @@ describe(AssetService.name, () => {
}); });
it('should restore a batch of assets', async () => { 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'] }); await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] });
@ -984,19 +980,19 @@ describe(AssetService.name, () => {
describe('run', () => { describe('run', () => {
it('should run the refresh metadata job', async () => { 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 }), await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }),
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } });
}); });
it('should run the refresh thumbnails job', async () => { 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 }), 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' } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } });
}); });
it('should run the transcode video', async () => { 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 }), await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }),
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
}); });
@ -1004,9 +1000,7 @@ describe(AssetService.name, () => {
describe('updateStackParent', () => { describe('updateStackParent', () => {
it('should require asset update access for new parent', async () => { it('should require asset update access for new parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old']));
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.updateStackParent(authStub.user1, { sut.updateStackParent(authStub.user1, {
oldParentId: 'old', oldParentId: 'old',
@ -1016,8 +1010,7 @@ describe(AssetService.name, () => {
}); });
it('should require asset read access for old parent', async () => { it('should require asset read access for old parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new']));
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
await expect( await expect(
sut.updateStackParent(authStub.user1, { sut.updateStackParent(authStub.user1, {
oldParentId: 'old', oldParentId: 'old',
@ -1027,7 +1020,9 @@ describe(AssetService.name, () => {
}); });
it('make old parent the child of new parent', async () => { 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) when(assetMock.getById)
.calledWith(assetStub.image.id) .calledWith(assetStub.image.id)
.mockResolvedValue(assetStub.image as AssetEntity); .mockResolvedValue(assetStub.image as AssetEntity);
@ -1041,7 +1036,9 @@ describe(AssetService.name, () => {
}); });
it('remove stackParentId of new parent', async () => { 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, { await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id, oldParentId: assetStub.primaryImage.id,
newParentId: 'new', newParentId: 'new',
@ -1051,7 +1048,8 @@ describe(AssetService.name, () => {
}); });
it('update stackParentId of old parents children to new parent', async () => { 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) when(assetMock.getById)
.calledWith(assetStub.primaryImage.id) .calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity); .mockResolvedValue(assetStub.primaryImage as AssetEntity);

View file

@ -98,7 +98,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
tags: entity.tags?.map(mapTag), tags: entity.tags?.map(mapTag),
people: entity.faces people: entity.faces
?.map(mapFace) ?.map(mapFace)
.filter((person): person is PersonResponseDto => person !== null && !person.isHidden) .filter((person): person is PersonResponseDto => person !== null)
.reduce((people, person) => { .reduce((people, person) => {
const existingPerson = people.find((p) => p.id === person.id); const existingPerson = people.find((p) => p.id === person.id);
if (!existingPerson) { if (!existingPerson) {

View file

@ -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. // They should be replaced with native Set operations, when they are added to the language.
// Proposal reference: https://github.com/tc39/proposal-set-methods // Proposal reference: https://github.com/tc39/proposal-set-methods
export const setUnion = <T>(setA: Set<T>, setB: Set<T>): Set<T> => { export const setUnion = <T>(...sets: Set<T>[]): Set<T> => {
const union = new Set(setA); const union = new Set(sets[0]);
for (const elem of setB) { for (const set of sets.slice(1)) {
union.add(elem); for (const elem of set) {
union.add(elem);
}
} }
return union; return union;
}; };
export const setDifference = <T>(setA: Set<T>, setB: Set<T>): Set<T> => { export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => {
const difference = new Set(setA); const difference = new Set(setA);
for (const elem of setB) { for (const set of sets) {
difference.delete(elem); for (const elem of set) {
difference.delete(elem);
}
} }
return difference; return difference;
}; };
export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => {
for (const elem of subset) {
if (!set.has(elem)) {
return false;
}
}
return true;
};
export const setIsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean => {
return setA.size === setB.size && setIsSuperset(setA, setB);
};

View file

@ -58,7 +58,8 @@ describe(LibraryService.name, () => {
ctime: new Date('2023-01-01'), ctime: new Date('2023-01-01'),
} as Stats); } 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( sut = new LibraryService(
accessMock, accessMock,

View file

@ -331,7 +331,7 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValue(personStub.withName); personMock.getById.mockResolvedValue(personStub.withName);
personMock.update.mockResolvedValue(personStub.withName); personMock.update.mockResolvedValue(personStub.withName);
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); 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'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect( await expect(

View file

@ -6,11 +6,12 @@ export interface IAccessRepository {
hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>; hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasCreateAccess(userId: string, albumId: string): Promise<boolean>; hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
}; };
asset: { asset: {
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>; checkOwnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
hasAlbumAccess(userId: string, assetId: string): Promise<boolean>; checkAlbumAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
hasPartnerAccess(userId: string, assetId: string): Promise<boolean>; checkPartnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise<boolean>; checkSharedLinkAccess(sharedLinkId: string, assetIds: Set<string>): Promise<Set<string>>;
}; };
authDevice: { authDevice: {

View file

@ -11,7 +11,6 @@ import {
sharedLinkResponseStub, sharedLinkResponseStub,
sharedLinkStub, sharedLinkStub,
} from '@test'; } from '@test';
import { when } from 'jest-when';
import _ from 'lodash'; import _ from 'lodash';
import { AssetIdErrorReason } from '../asset'; import { AssetIdErrorReason } from '../asset';
import { ICryptoRepository, ISharedLinkRepository } from '../repositories'; import { ICryptoRepository, ISharedLinkRepository } from '../repositories';
@ -109,7 +108,6 @@ describe(SharedLinkService.name, () => {
}); });
it('should require asset ownership to make an individual shared link', async () => { it('should require asset ownership to make an individual shared link', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }), sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
@ -140,7 +138,7 @@ describe(SharedLinkService.name, () => {
}); });
it('should create an individual shared link', async () => { 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); shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
@ -151,7 +149,7 @@ describe(SharedLinkService.name, () => {
allowUpload: true, 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({ expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.id, userId: authStub.admin.id,
@ -215,9 +213,7 @@ describe(SharedLinkService.name, () => {
it('should add assets to a shared link', async () => { it('should add assets to a shared link', async () => {
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual); shareMock.create.mockResolvedValue(sharedLinkStub.individual);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3']));
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
await expect( await expect(
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), 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 }, { assetId: 'asset-3', success: true },
]); ]);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
expect(shareMock.update).toHaveBeenCalledWith({ expect(shareMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual, ...sharedLinkStub.individual,
assets: [assetStub.image, { id: 'asset-3' }], assets: [assetStub.image, { id: 'asset-3' }],

View file

@ -232,54 +232,49 @@ describe('AssetService', () => {
describe('getAssetById', () => { describe('getAssetById', () => {
it('should allow owner access', async () => { 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); assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id); 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 () => { 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); assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.adminSharedLink, assetStub.image.id); await sut.getAssetById(authStub.adminSharedLink, assetStub.image.id);
expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId, authStub.adminSharedLink.sharedLinkId,
assetStub.image.id, new Set([assetStub.image.id]),
); );
}); });
it('should allow partner sharing access', async () => { it('should allow partner sharing access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetStub.image); assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id); 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 () => { it('should allow shared album access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id]));
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetStub.image); assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id); 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 () => { 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); await expect(sut.getAssetById(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).not.toHaveBeenCalled(); expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
}); });
it('should throw an error for an invalid shared link', async () => { 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( await expect(sut.getAssetById(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled(); expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
expect(assetRepositoryMock.getById).not.toHaveBeenCalled(); expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
}); });
}); });

View file

@ -1,6 +1,6 @@
import { IAccessRepository } from '@app/domain'; import { IAccessRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { Brackets, In, Repository } from 'typeorm';
import { import {
ActivityEntity, ActivityEntity,
AlbumEntity, AlbumEntity,
@ -112,107 +112,120 @@ export class AccessRepository implements IAccessRepository {
}; };
asset = { asset = {
hasAlbumAccess: (userId: string, assetId: string): Promise<boolean> => { checkAlbumAccess: async (userId: string, assetIds: Set<string>): Promise<Set<string>> => {
return this.albumRepository.exist({ if (assetIds.size === 0) {
where: [ 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<string>();
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<string>): Promise<Set<string>> => {
if (assetIds.size === 0) {
return new Set();
}
return this.assetRepository
.find({
select: { id: true },
where: {
id: In([...assetIds]),
ownerId: userId, ownerId: userId,
assets: {
id: assetId,
},
}, },
{ withDeleted: true,
sharedUsers: { })
id: userId, .then((assets) => new Set(assets.map((asset) => asset.id)));
},
assets: {
id: assetId,
},
},
// still part of a live photo is in an album
{
ownerId: userId,
assets: {
livePhotoVideoId: assetId,
},
},
{
sharedUsers: {
id: userId,
},
assets: {
livePhotoVideoId: assetId,
},
},
],
});
}, },
hasOwnerAccess: (userId: string, assetId: string): Promise<boolean> => { checkPartnerAccess: async (userId: string, assetIds: Set<string>): Promise<Set<string>> => {
return this.assetRepository.exist({ if (assetIds.size === 0) {
where: { return new Set();
id: assetId, }
ownerId: userId,
}, return this.partnerRepository
withDeleted: true, .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<boolean> => { checkSharedLinkAccess: async (sharedLinkId: string, assetIds: Set<string>): Promise<Set<string>> => {
return this.partnerRepository.exist({ if (assetIds.size === 0) {
where: { return new Set();
sharedWith: { }
id: userId,
},
sharedBy: {
assets: {
id: assetId,
},
},
},
relations: {
sharedWith: true,
sharedBy: {
assets: true,
},
},
});
},
hasSharedLinkAccess: async (sharedLinkId: string, assetId: string): Promise<boolean> => { return this.sharedLinkRepository
return this.sharedLinkRepository.exist({ .createQueryBuilder('sharedLink')
where: [ .leftJoin('sharedLink.album', 'album')
{ .leftJoin('sharedLink.assets', 'assets')
id: sharedLinkId, .leftJoin('album.assets', 'albumAssets')
album: { .select('assets.id', 'assetId')
assets: { .addSelect('albumAssets.id', 'albumAssetId')
id: assetId, .addSelect('assets.livePhotoVideoId', 'assetLivePhotoVideoId')
}, .addSelect('albumAssets.livePhotoVideoId', 'albumAssetLivePhotoVideoId')
}, .where('sharedLink.id = :sharedLinkId', { sharedLinkId })
}, .andWhere(
{ new Brackets((qb) => {
id: sharedLinkId, qb.where('assets.id IN (:...assetIds)', { assetIds: [...assetIds] })
assets: { .orWhere('albumAssets.id IN (:...assetIds)', { assetIds: [...assetIds] })
id: assetId, // 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] });
// still part of a live photo is in a shared link }),
{ )
id: sharedLinkId, .getRawMany()
album: { .then((rows) => {
assets: { const allowedIds = new Set<string>();
livePhotoVideoId: assetId, for (const row of rows) {
}, if (row.assetId && assetIds.has(row.assetId)) {
}, allowedIds.add(row.assetId);
}, }
{ if (row.assetLivePhotoVideoId && assetIds.has(row.assetLivePhotoVideoId)) {
id: sharedLinkId, allowedIds.add(row.assetLivePhotoVideoId);
assets: { }
livePhotoVideoId: assetId, if (row.albumAssetId && assetIds.has(row.albumAssetId)) {
}, allowedIds.add(row.albumAssetId);
}, }
], if (row.albumAssetLivePhotoVideoId && assetIds.has(row.albumAssetLivePhotoVideoId)) {
}); allowedIds.add(row.albumAssetLivePhotoVideoId);
}
}
return allowedIds;
});
}, },
}; };

View file

@ -1,5 +1,5 @@
import { bootstrap as adminCli } from './admin-cli/main'; import { bootstrap as admin } from './immich-admin/main';
import { bootstrap as immich } from './immich/main'; import { bootstrap as server } from './immich/main';
import { bootstrap as microservices } from './microservices/main'; import { bootstrap as microservices } from './microservices/main';
const immichApp = process.argv[2] || process.env.IMMICH_APP; const immichApp = process.argv[2] || process.env.IMMICH_APP;
@ -12,13 +12,13 @@ function bootstrap() {
switch (immichApp) { switch (immichApp) {
case 'immich': case 'immich':
process.title = 'immich_server'; process.title = 'immich_server';
return immich(); return server();
case 'microservices': case 'microservices':
process.title = 'immich_microservices'; process.title = 'immich_microservices';
return microservices(); return microservices();
case 'admin-cli': case 'immich-admin':
process.title = 'immich_admin_cli'; process.title = 'immich_admin_cli';
return adminCli(); return admin();
default: default:
console.log(`Invalid app name: ${immichApp}. Expected one of immich|microservices|cli`); console.log(`Invalid app name: ${immichApp}. Expected one of immich|microservices|cli`);
process.exit(1); process.exit(1);

View file

@ -32,4 +32,4 @@ if [ "$REDIS_PASSWORD_FILE" ]; then
unset REDIS_PASSWORD_FILE unset REDIS_PASSWORD_FILE
fi fi
exec node dist/main $@ exec node /usr/src/app/dist/main $@

View file

@ -22,11 +22,12 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
hasAlbumOwnerAccess: jest.fn(), hasAlbumOwnerAccess: jest.fn(),
hasCreateAccess: jest.fn(), hasCreateAccess: jest.fn(),
}, },
asset: { asset: {
hasOwnerAccess: jest.fn(), checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
hasAlbumAccess: jest.fn(), checkAlbumAccess: jest.fn().mockResolvedValue(new Set()),
hasPartnerAccess: jest.fn(), checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
hasSharedLinkAccess: jest.fn(), checkSharedLinkAccess: jest.fn().mockResolvedValue(new Set()),
}, },
album: { album: {

View file

@ -144,7 +144,7 @@
<main <main
class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40" class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
> >
<AssetGrid {album} {user} {assetStore} {assetInteractionStore}> <AssetGrid {album} {assetStore} {assetInteractionStore}>
<section class="pt-24"> <section class="pt-24">
<!-- ALBUM TITLE --> <!-- ALBUM TITLE -->
<p <p

View file

@ -9,7 +9,6 @@
AssetTypeEnum, AssetTypeEnum,
ReactionType, ReactionType,
SharedLinkResponseDto, SharedLinkResponseDto,
UserResponseDto,
} from '@api'; } from '@api';
import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@ -42,6 +41,7 @@
import { updateNumberOfComments } from '$lib/stores/activity.store'; import { updateNumberOfComments } from '$lib/stores/activity.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import SlideshowBar from './slideshow-bar.svelte'; import SlideshowBar from './slideshow-bar.svelte';
import { user } from '$lib/stores/user.store';
export let assetStore: AssetStore | null = null; export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
@ -51,7 +51,6 @@
export let force = false; export let force = false;
export let withStacked = false; export let withStacked = false;
export let isShared = false; export let isShared = false;
export let user: UserResponseDto | null = null;
export let album: AlbumResponseDto | null = null; export let album: AlbumResponseDto | null = null;
let reactions: ActivityResponseDto[] = []; let reactions: ActivityResponseDto[] = [];
@ -142,10 +141,10 @@
}; };
const getFavorite = async () => { const getFavorite = async () => {
if (album && user) { if (album && $user) {
try { try {
const { data } = await api.activityApi.getActivities({ const { data } = await api.activityApi.getActivities({
userId: user.id, userId: $user.id,
assetId: asset.id, assetId: asset.id,
albumId: album.id, albumId: album.id,
type: ReactionType.Like, type: ReactionType.Like,
@ -753,7 +752,7 @@
</div> </div>
{/if} {/if}
{#if isShared && album && isShowActivity && user} {#if isShared && album && isShowActivity && $user}
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="activity-panel" id="activity-panel"
@ -761,7 +760,7 @@
translate="yes" translate="yes"
> >
<ActivityViewer <ActivityViewer
{user} user={$user}
disabled={!album.isActivityEnabled} disabled={!album.isActivityEnabled}
assetType={asset.type} assetType={asset.type}
albumOwnerId={album.ownerId} albumOwnerId={album.ownerId}

View file

@ -19,13 +19,17 @@
mdiImageOutline, mdiImageOutline,
mdiMapMarkerOutline, mdiMapMarkerOutline,
mdiInformationOutline, mdiInformationOutline,
mdiEye,
mdiEyeOff,
} from '@mdi/js'; } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import Map from '../shared-components/map/map.svelte'; import Map from '../shared-components/map/map.svelte';
import { websocketStore } from '$lib/stores/websocket'; import { websocketStore } from '$lib/stores/websocket';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import ChangeLocation from '../shared-components/change-location.svelte'; import ChangeLocation from '../shared-components/change-location.svelte';
import { handleError } from '../../utils/handle-error'; import { handleError } from '../../utils/handle-error';
import { user } from '$lib/stores/user.store';
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = []; export let albums: AlbumResponseDto[] = [];
@ -56,6 +60,7 @@
})(); })();
$: people = asset.people || []; $: people = asset.people || [];
$: showingHiddenPeople = false;
const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => { const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => {
if (assetUpdate && assetUpdate.id === asset.id) { if (assetUpdate && assetUpdate.id === asset.id) {
@ -176,25 +181,38 @@
{#if !api.isSharedLink && people.length > 0} {#if !api.isSharedLink && people.length > 0}
<section class="px-4 py-4 text-sm"> <section class="px-4 py-4 text-sm">
<h2>PEOPLE</h2> <div class="flex h-10 w-full items-center justify-between">
<h2>PEOPLE</h2>
{#if people.some((person) => person.isHidden)}
<CircleIconButton
title="Show hidden people"
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
padding="1"
on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
</div>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{#each people as person (person.id)} {#each people as person (person.id)}
<a <a
href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}" href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
class="w-[90px]" class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
on:click={() => dispatch('close-viewer')} on:click={() => dispatch('close-viewer')}
> >
<ImageThumbnail <div class="relative">
curve <ImageThumbnail
shadow curve
url={api.getPeopleThumbnailUrl(person.id)} shadow
altText={person.name} url={api.getPeopleThumbnailUrl(person.id)}
title={person.name} altText={person.name}
widthStyle="90px" title={person.name}
heightStyle="90px" widthStyle="90px"
thumbhash={null} heightStyle="90px"
/> thumbhash={null}
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p> <p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate} {#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)} {@const personBirthDate = DateTime.fromISO(person.birthDate)}
@ -238,12 +256,14 @@
zone: asset.exifInfo.timeZone ?? undefined, zone: asset.exifInfo.timeZone ?? undefined,
})} })}
<div <div
class="flex justify-between place-items-start gap-4 py-4 hover:dark:text-immich-dark-primary hover:text-immich-primary cursor-pointer" class="flex justify-between place-items-start gap-4 py-4"
on:click={() => (isShowChangeDate = true)}
on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)}
tabindex="0" tabindex="0"
role="button" role="button"
title="Edit date" on:click={() => (isOwner ? (isShowChangeDate = true) : null)}
on:keydown={(event) => (isOwner ? event.key === 'Enter' && (isShowChangeDate = true) : null)}
title={isOwner ? 'Edit date' : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div> <div>
@ -276,11 +296,14 @@
</div> </div>
</div> </div>
</div> </div>
<button class="focus:outline-none">
<Icon path={mdiPencil} size="20" /> {#if isOwner}
</button> <button class="focus:outline-none">
<Icon path={mdiPencil} size="20" />
</button>
{/if}
</div> </div>
{:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly} {:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly && $user && asset.ownerId === $user.id}
<div class="flex justify-between place-items-start gap-4 py-4"> <div class="flex justify-between place-items-start gap-4 py-4">
<div class="flex gap-4"> <div class="flex gap-4">
<div> <div>
@ -410,12 +433,14 @@
{#if asset.exifInfo?.city && !asset.isReadOnly} {#if asset.exifInfo?.city && !asset.isReadOnly}
<div <div
class="flex justify-between place-items-start gap-4 py-4 hover:dark:text-immich-dark-primary hover:text-immich-primary cursor-pointer" class="flex justify-between place-items-start gap-4 py-4"
on:click={() => (isShowChangeLocation = true)} on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} on:keydown={(event) => (isOwner ? event.key === 'Enter' && (isShowChangeLocation = true) : null)}
tabindex="0" tabindex="0"
title={isOwner ? 'Edit location' : ''}
role="button" role="button"
title="Edit location" class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div> <div><Icon path={mdiMapMarkerOutline} size="24" /></div>
@ -435,11 +460,13 @@
</div> </div>
</div> </div>
<div> {#if isOwner}
<Icon path={mdiPencil} size="20" /> <div>
</div> <Icon path={mdiPencil} size="20" />
</div>
{/if}
</div> </div>
{:else if !asset.exifInfo?.city && !asset.isReadOnly} {:else if !asset.exifInfo?.city && !asset.isReadOnly && $user && asset.ownerId === $user.id}
<div <div
class="flex justify-between place-items-start gap-4 py-4 rounded-lg pr-2 hover:dark:text-immich-dark-primary hover:text-immich-primary" class="flex justify-between place-items-start gap-4 py-4 rounded-lg pr-2 hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)} on:click={() => (isShowChangeLocation = true)}

View file

@ -5,6 +5,8 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.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; export let menuItem = false;
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
@ -12,9 +14,7 @@
const handleConfirm = async (dateTimeOriginal: string) => { const handleConfirm = async (dateTimeOriginal: string) => {
isShowChangeDate = false; isShowChangeDate = false;
const ids = Array.from(getOwnedAssets()) const ids = getSelectedAssets(getOwnedAssets(), $user);
.filter((a) => !a.isExternal)
.map((a) => a.id);
try { try {
await api.assetApi.updateAssets({ await api.assetApi.updateAssets({

View file

@ -4,6 +4,8 @@
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import { handleError } from '../../../utils/handle-error'; import { handleError } from '../../../utils/handle-error';
import { user } from '$lib/stores/user.store';
import { getSelectedAssets } from '$lib/utils/asset-utils';
export let menuItem = false; export let menuItem = false;
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
@ -12,9 +14,7 @@
async function handleConfirm(point: { lng: number; lat: number }) { async function handleConfirm(point: { lng: number; lat: number }) {
isShowChangeLocation = false; isShowChangeLocation = false;
const ids = Array.from(getOwnedAssets()) const ids = getSelectedAssets(getOwnedAssets(), $user);
.filter((a) => !a.isExternal)
.map((a) => a.id);
try { try {
await api.assetApi.updateAssets({ await api.assetApi.updateAssets({

View file

@ -8,7 +8,7 @@
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store'; import { isSearchEnabled } from '$lib/stores/search.store';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util'; 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 { DateTime } from 'luxon';
import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@ -27,7 +27,6 @@
export let removeAction: AssetAction | null = null; export let removeAction: AssetAction | null = null;
export let withStacked = false; export let withStacked = false;
export let isShared = false; export let isShared = false;
export let user: UserResponseDto | null = null;
export let album: AlbumResponseDto | null = null; export let album: AlbumResponseDto | null = null;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
@ -394,7 +393,6 @@
<Portal target="body"> <Portal target="body">
{#if $showAssetViewer} {#if $showAssetViewer}
<AssetViewer <AssetViewer
{user}
{withStacked} {withStacked}
{assetStore} {assetStore}
asset={$viewingAsset} asset={$viewingAsset}

View file

@ -2,7 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { downloadArchive } from '$lib/utils/asset-utils'; import { downloadArchive } from '$lib/utils/asset-utils';
import { api, AssetResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api'; import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
@ -17,7 +17,6 @@
export let sharedLink: SharedLinkResponseDto; export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean; export let isOwned: boolean;
export let user: UserResponseDto | undefined = undefined;
let selectedAssets: Set<AssetResponseDto> = new Set(); let selectedAssets: Set<AssetResponseDto> = new Set();
@ -103,6 +102,6 @@
</ControlAppBar> </ControlAppBar>
{/if} {/if}
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40"> <section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {user} {assets} bind:selectedAssets /> <GalleryViewer {assets} bind:selectedAssets />
</section> </section>
</section> </section>

View file

@ -2,7 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { handleError } from '$lib/utils/handle-error'; 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 AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { getThumbnailSize } from '$lib/utils/thumbnail-util'; import { getThumbnailSize } from '$lib/utils/thumbnail-util';
@ -13,7 +13,6 @@
export let selectedAssets: Set<AssetResponseDto> = new Set(); export let selectedAssets: Set<AssetResponseDto> = new Set();
export let disableAssetSelect = false; export let disableAssetSelect = false;
export let showArchiveIcon = false; export let showArchiveIcon = false;
export let user: UserResponseDto | undefined = undefined;
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
@ -109,7 +108,6 @@
<!-- Overlay Asset Viewer --> <!-- Overlay Asset Viewer -->
{#if $showAssetViewer} {#if $showAssetViewer}
<AssetViewer <AssetViewer
{user}
asset={selectedAsset} asset={selectedAsset}
on:previous={navigateAssetBackward} on:previous={navigateAssetBackward}
on:next={navigateAssetForward} on:next={navigateAssetForward}

View file

@ -2,19 +2,18 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { websocketStore } from '$lib/stores/websocket'; import { websocketStore } from '$lib/stores/websocket';
import { ServerInfoResponseDto, api } from '@api'; import { api } from '@api';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { asByteUnitString } from '../../utils/byte-units'; import { asByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte'; import LoadingSpinner from './loading-spinner.svelte';
import { mdiCloud, mdiDns } from '@mdi/js'; import { mdiCloud, mdiDns } from '@mdi/js';
import { serverInfoStore } from '$lib/stores/server-info.store';
const { serverVersion, connected } = websocketStore; const { serverVersion, connected } = websocketStore;
let serverInfo: ServerInfoResponseDto;
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null; $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: usedPercentage = Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100); $: usedPercentage = Math.round(($serverInfoStore?.diskUseRaw / $serverInfoStore?.diskSizeRaw) * 100);
onMount(async () => { onMount(async () => {
await refresh(); await refresh();
@ -23,7 +22,7 @@
const refresh = async () => { const refresh = async () => {
try { try {
const { data } = await api.serverInfoApi.getServerInfo(); const { data } = await api.serverInfoApi.getServerInfo();
serverInfo = data; $serverInfoStore = data;
} catch (e) { } catch (e) {
console.log('Error [StatusBox] [onMount]'); console.log('Error [StatusBox] [onMount]');
} }
@ -44,7 +43,7 @@
</div> </div>
<div class="hidden group-hover:sm:block md:block"> <div class="hidden group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p> <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
{#if serverInfo} {#if $serverInfoStore}
<div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700"> <div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<!-- style={`width: ${$downloadAssets[fileName]}%`} --> <!-- style={`width: ${$downloadAssets[fileName]}%`} -->
<div <div
@ -53,8 +52,8 @@
/> />
</div> </div>
<p class="text-xs"> <p class="text-xs">
{asByteUnitString(serverInfo?.diskUseRaw, $locale)} of {asByteUnitString($serverInfoStore?.diskUseRaw, $locale)} of
{asByteUnitString(serverInfo?.diskSizeRaw, $locale)} used {asByteUnitString($serverInfoStore?.diskSizeRaw, $locale)} used
</p> </p>
{:else} {:else}
<div class="mt-2"> <div class="mt-2">

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { ServerInfoResponseDto } from '@api';
export const serverInfoStore = writable<ServerInfoResponseDto>();

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { UserResponseDto } from '@api';
export const user = writable<UserResponseDto | null>(null);

View file

@ -1,6 +1,14 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { downloadManager } from '$lib/stores/download'; import { downloadManager } from '$lib/stores/download';
import { api, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto, DownloadInfoDto, AssetTypeEnum } from '@api'; import {
api,
BulkIdResponseDto,
AssetResponseDto,
DownloadResponseDto,
DownloadInfoDto,
AssetTypeEnum,
UserResponseDto,
} from '@api';
import { handleError } from './handle-error'; import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: Array<string>): Promise<BulkIdResponseDto[]> => export const addAssetsToAlbum = async (albumId: string, assetIds: Array<string>): Promise<BulkIdResponseDto[]> =>
@ -203,3 +211,17 @@ export const getAssetType = (type: AssetTypeEnum) => {
return 'Asset'; return 'Asset';
} }
}; };
export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserResponseDto | null): string[] => {
const ids = Array.from(assets)
.filter((a) => !a.isExternal && user && a.ownerId !== user.id)
.map((a) => a.id);
const numberOfIssues = Array.from(assets).filter((a) => a.isExternal || (user && a.ownerId === user.id)).length;
if (numberOfIssues > 0) {
notificationController.show({
message: `Can't change metadata of ${numberOfIssues} asset${numberOfIssues > 1 ? 's' : ''}`,
type: NotificationType.Warning,
});
}
return ids;
};

View file

@ -59,6 +59,7 @@
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import AlbumOptions from '$lib/components/album-page/album-options.svelte'; import AlbumOptions from '$lib/components/album-page/album-options.svelte';
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte'; import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
import { user } from '$lib/stores/user.store';
export let data: PageData; export let data: PageData;
@ -66,6 +67,9 @@
let { slideshowState, slideshowShuffle } = slideshowStore; let { slideshowState, slideshowShuffle } = slideshowStore;
let album = data.album; let album = data.album;
$user = data.user;
$: album = data.album; $: album = data.album;
$: { $: {
@ -96,7 +100,6 @@
let isShowActivity = false; let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null; let isLiked: ActivityResponseDto | null = null;
let reactions: ActivityResponseDto[] = []; let reactions: ActivityResponseDto[] = [];
let user = data.user;
let globalWidth: number; let globalWidth: number;
let assetGridWidth: number; let assetGridWidth: number;
@ -179,10 +182,10 @@
}; };
const getFavorite = async () => { const getFavorite = async () => {
if (user) { if ($user) {
try { try {
const { data } = await api.activityApi.getActivities({ const { data } = await api.activityApi.getActivities({
userId: user.id, userId: $user.id,
albumId: album.id, albumId: album.id,
type: ReactionType.Like, type: ReactionType.Like,
level: ReactionLevel.Album, level: ReactionLevel.Album,
@ -549,16 +552,10 @@
style={`width:${assetGridWidth}px`} style={`width:${assetGridWidth}px`}
> >
{#if viewMode === ViewMode.SELECT_ASSETS} {#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid <AssetGrid assetStore={timelineStore} assetInteractionStore={timelineInteractionStore} isSelectionMode={true} />
user={data.user}
assetStore={timelineStore}
assetInteractionStore={timelineInteractionStore}
isSelectionMode={true}
/>
{:else} {:else}
<AssetGrid <AssetGrid
{album} {album}
user={data.user}
{assetStore} {assetStore}
{assetInteractionStore} {assetInteractionStore}
isShared={album.sharedUsers.length > 0} isShared={album.sharedUsers.length > 0}
@ -679,7 +676,7 @@
{/if} {/if}
</main> </main>
</div> </div>
{#if album.sharedUsers.length > 0 && album && isShowActivity && user && !$showAssetViewer} {#if album.sharedUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
<div class="flex"> <div class="flex">
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
@ -688,7 +685,7 @@
translate="yes" translate="yes"
> >
<ActivityViewer <ActivityViewer
{user} user={$user}
disabled={!album.isActivityEnabled} disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId} albumOwnerId={album.ownerId}
albumId={album.id} albumId={album.id}
@ -738,10 +735,10 @@
</ConfirmDialogue> </ConfirmDialogue>
{/if} {/if}
{#if viewMode === ViewMode.OPTIONS} {#if viewMode === ViewMode.OPTIONS && $user}
<AlbumOptions <AlbumOptions
{album} {album}
{user} user={$user}
on:close={() => (viewMode = ViewMode.VIEW)} on:close={() => (viewMode = ViewMode.VIEW)}
on:toggleEnableActivity={handleToggleEnableActivity} on:toggleEnableActivity={handleToggleEnableActivity}
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}

View file

@ -24,6 +24,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte'; import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
import { user } from '$lib/stores/user.store';
export let data: PageData; export let data: PageData;
@ -33,6 +34,8 @@
const assetInteractionStore = createAssetInteractionStore(); const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore; const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$user = data.user;
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
const handleEscape = () => { const handleEscape = () => {