diff --git a/Makefile b/Makefile index 0899d82d24..81f5262415 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ e2e: prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans +prod-down: + docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans + prod-scale: docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile new file mode 100644 index 0000000000..be4259f8b3 --- /dev/null +++ b/docker/caddy/Caddyfile @@ -0,0 +1,24 @@ +{ + # debug + local_certs + log { + format console + } + pki { + ca local { + name "Immich Local CA - TESTING ONLY" + intermediate_lifetime 3599d + } + } + grace_period 0 + shutdown_delay 0 + skip_install_trust + auto_https disable_redirects +} + +{$IMMICH_HOST}:3443 { + tls internal { + on_demand + } + reverse_proxy {$IMMICH_INTERNAL_URL} +} \ No newline at end of file diff --git a/docker/caddy/certs/README.md b/docker/caddy/certs/README.md new file mode 100644 index 0000000000..eee67c028f --- /dev/null +++ b/docker/caddy/certs/README.md @@ -0,0 +1,15 @@ +## What is in this folder? + +These are Caddy certificates necessary for local development using the service-worker, clipboard access, etc. + +This folder contains certs root and intermediate CAs. Caddy uses this to sign its server certs. + +These certificates have a 10yr expiration date. They should NOT be used in production. + +## How to use? +1. You should import these into your system keychain or truststore. (OS-specific) +2. Ensure 'immich-dev' resolves to the docker host. + * i.e. add entry in /etc/hosts that points to the host running the immich docker container. + +## Permissions +Caddy runs as root user. These files must be owned by root with 600 permissions. You make need to temporarily make these 644 so you can copy/import them into your trust store. \ No newline at end of file diff --git a/docker/caddy/certs/intermediate.crt b/docker/caddy/certs/intermediate.crt new file mode 100644 index 0000000000..215814a39b --- /dev/null +++ b/docker/caddy/certs/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB2TCCAX+gAwIBAgIQaofX+uLl1ohUu1tDEoKbdjAKBggqhkjOPQQDAjA5MTcw +NQYDVQQDEy5JbW1pY2ggTG9jYWwgQ0EgLSBURVNUSU5HIE9OTFkgLSAyMDI1IEVD +QyBSb290MB4XDTI1MDMxNTE1MTMxOVoXDTM1MDEyMTE1MTMxOVowPDE6MDgGA1UE +AxMxSW1taWNoIExvY2FsIENBIC0gVEVTVElORyBPTkxZIC0gRUNDIEludGVybWVk +aWF0ZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDIDN4PR36WU+XZYaUxzdRpd +R5PUJ34oeqnthRIvxz5k1v324pYvk/unKkr4/73+YiQgbGJYoXuS1RosMh6+J4aj +ZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQW +BBSgTH3mPyuKmXKSfUn/XC9Ag69trTAfBgNVHSMEGDAWgBRjdUoajCqc0KfFvLbw +sdJQqL6iCjAKBggqhkjOPQQDAgNIADBFAiEA2zQBXgof4D7pk9RF/J5MKCMi+mGq +s8I8MQM0X0PWv6wCIG8R0KOvwiYPxsX+TDUtG4F2rYdSb6OHbcoYg0UEwMVZ +-----END CERTIFICATE----- diff --git a/docker/caddy/certs/intermediate.key b/docker/caddy/certs/intermediate.key new file mode 100644 index 0000000000..350e9b4ac9 --- /dev/null +++ b/docker/caddy/certs/intermediate.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIPO0Ao4ha+T3Op2UljmdroXbvsDrKYMqGvu9762W+mZqoAoGCCqGSM49 +AwEHoUQDQgAEMgM3g9HfpZT5dlhpTHN1Gl1Hk9Qnfih6qe2FEi/HPmTW/fbili+T ++6cqSvj/vf5iJCBsYlihe5LVGiwyHr4nhg== +-----END EC PRIVATE KEY----- diff --git a/docker/caddy/certs/root.crt b/docker/caddy/certs/root.crt new file mode 100644 index 0000000000..4acf46212a --- /dev/null +++ b/docker/caddy/certs/root.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAVygAwIBAgIRAMd1v26Z7/BEBZVgNeUSPD8wCgYIKoZIzj0EAwIwOTE3 +MDUGA1UEAxMuSW1taWNoIExvY2FsIENBIC0gVEVTVElORyBPTkxZIC0gMjAyNSBF +Q0MgUm9vdDAeFw0yNTAzMTUxNTEzMTlaFw0zNTAxMjIxNTEzMTlaMDkxNzA1BgNV +BAMTLkltbWljaCBMb2NhbCBDQSAtIFRFU1RJTkcgT05MWSAtIDIwMjUgRUNDIFJv +b3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATcZGmAJUrSce1rOvNPcSAM9hDS +/9NopYW9833n52kqrC+ArUZsMHC2BxN5Ndlu+ac288oSrUKLOxzes0Lr+Jeto0Uw +QzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQU +Y3VKGowqnNCnxby28LHSUKi+ogowCgYIKoZIzj0EAwIDSQAwRgIhAOQMD95mhs6G +qxzoMXbYgjw5S5cF4HP4yYBYcvrmuypVAiEAlG//Ayx9kicVHVeOchm4RyRCm1hU +zEBhaqC33ivd4D8= +-----END CERTIFICATE----- diff --git a/docker/caddy/certs/root.key b/docker/caddy/certs/root.key new file mode 100644 index 0000000000..c5cc9bc59e --- /dev/null +++ b/docker/caddy/certs/root.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDrpG22VrpagAKo7dPL16RihojPr7MgYcKwZA5jSMrXioAoGCCqGSM49 +AwEHoUQDQgAE3GRpgCVK0nHtazrzT3EgDPYQ0v/TaKWFvfN95+dpKqwvgK1GbDBw +tgcTeTXZbvmnNvPKEq1Cizsc3rNC6/iXrQ== +-----END EC PRIVATE KEY----- diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index f2f814fbd0..89ed673d14 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -14,6 +14,21 @@ name: immich-dev services: + immich-caddy: + container_name: immich_caddy + image: caddy:2.9.1-alpine + restart: unless-stopped + ports: + - "2019:2019" + - "3443:3443" + - "3443:3443/udp" + environment: + IMMICH_HOST: immich-dev + IMMICH_INTERNAL_URL: http://immich-web:3000 + volumes: + - ./caddy:/etc/caddy + - ./caddy/certs:/data/caddy/pki/authorities/local + command: ["/bin/sh", "-c", "chown 0:0 /data/caddy/pki/authorities/local/*; chmod 600 /data/caddy/pki/authorities/local/*; caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"] immich-server: container_name: immich_server command: ['/usr/src/app/bin/immich-dev'] diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 559dd55e72..554f539b3a 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -10,6 +10,20 @@ name: immich-prod services: + immich-caddy: + container_name: immich_caddy + image: caddy:2.9.1-alpine + restart: unless-stopped + ports: + - "3443:3443" + - "3443:3443/udp" + environment: + IMMICH_HOST: immich-dev + IMMICH_INTERNAL_URL: http://immich-server:2283 + volumes: + - ./caddy:/etc/caddy + - ./caddy/certs:/data/caddy/pki/authorities/local + command: ["/bin/sh", "-c", "chown 0:0 /data/caddy/pki/authorities/local/*; chmod 600 /data/caddy/pki/authorities/local/*; caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"] immich-server: container_name: immich_server image: immich-server:latest diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index f855a99c53..9609fc7a5f 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -64,6 +64,8 @@ export default [ }, }, + ignores: ['**/service-worker/**'], + rules: { '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 70467ccb82..d46b7e251c 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -21,6 +21,7 @@ import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; interface Props { asset: AssetResponseDto; @@ -71,8 +72,7 @@ const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => { for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash); + preloadImageUrl(getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash)); } } }; @@ -150,6 +150,7 @@ return () => { loader?.removeEventListener('load', onload); loader?.removeEventListener('error', onerror); + cancelImageUrl(imageLoaderUrl); }; }); let isWebCompatible = $derived(isWebCompatibleImage(asset)); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 9d69bdeeb2..7d1c25b9e5 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -2,9 +2,10 @@ import { thumbhash } from '$lib/actions/thumbhash'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import Icon from '$lib/components/elements/icon.svelte'; + import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { TUNABLES } from '$lib/utils/tunables'; import { mdiEyeOffOutline } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import { fade } from 'svelte/transition'; interface Props { @@ -64,6 +65,9 @@ setLoaded(); } }); + onDestroy(() => { + cancelImageUrl(url); + }); let optionalClasses = $derived( [ diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts new file mode 100644 index 0000000000..1a19d3c134 --- /dev/null +++ b/web/src/lib/utils/sw-messaging.ts @@ -0,0 +1,8 @@ +const broadcast = new BroadcastChannel('immich'); + +export function cancelImageUrl(url: string) { + broadcast.postMessage({ type: 'cancel', url }); +} +export function preloadImageUrl(url: string) { + broadcast.postMessage({ type: 'preload', url }); +} diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts new file mode 100644 index 0000000000..ea2c2e41ad --- /dev/null +++ b/web/src/service-worker/index.ts @@ -0,0 +1,137 @@ +/// +/// +/// +/// + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; +import { build, files, version } from '$service-worker'; + +const pendingLoads = new Map(); +const useCache = true; +// Create a unique cache name for this deployment +const CACHE = `cache-${version}`; + +const APP_RESOURCES = [ + ...build, // the app itself + ...files, // everything in `static` +]; + +sw.addEventListener('install', (event) => { + event.waitUntil(sw.skipWaiting()); + // Create a new cache and add all files to it + event.waitUntil(addFilesToCache()); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil(sw.clients.claim()); + // Remove previous cached data from disk + event.waitUntil(deleteOldCaches()); +}); + +sw.addEventListener('fetch', (event) => { + // ignore POST requests etc + if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); + if (APP_RESOURCES.includes(url.pathname)) { + event.respondWith(appResources(url, event)); + } else if (/^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(url.pathname)) { + event.respondWith(immichAsset(url)); + } +}); + +async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } +} + +async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(APP_RESOURCES); +} + +async function immichAsset(url: URL) { + const cache = await caches.open(CACHE); + let response = useCache ? await cache.match(url) : undefined; + if (!response) { + try { + const cancelToken = new AbortController(); + const request = fetch(url, { + signal: cancelToken.signal, + }); + pendingLoads.set(url.toString(), cancelToken); + response = await request; + if (!(response instanceof Response)) { + throw new TypeError('invalid response from fetch'); + } + if (response.status === 200) { + cache.put(url, response.clone()); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((error as any).name !== 'AbortError') { + throw error; + } + } finally { + pendingLoads.delete(url.toString()); + } + } + return response as Response; +} + +async function appResources(url: URL, event: FetchEvent) { + const cache = await caches.open(CACHE); + // `build`/`files` can always be served from the cache + if (APP_RESOURCES.includes(url.pathname)) { + const response = await cache.match(url.pathname); + if (response) { + return response; + } + } + // for everything else, try the network first, but + // fall back to the cache if we're offline + try { + const response = await fetch(event.request); + // if we're offline, fetch can return a value that is not a Response + // instead of throwing - and we can't pass this non-Response to respondWith + if (!(response instanceof Response)) { + throw new TypeError('invalid response from fetch'); + } + + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + + return response; + } catch (error) { + const response = await cache.match(event.request); + if (response) { + return response; + } + // if there's no cache, then just error out + // as there is nothing we can do to respond to this request + throw error; + } +} + +const broadcast = new BroadcastChannel('immich'); +// eslint-disable-next-line unicorn/prefer-add-event-listener +broadcast.onmessage = (event) => { + if (!event.data) { + return; + } + if (event.data.type === 'cancel') { + const urlstring = event.data.url; + const url = new URL(urlstring, event.origin); + + const pending = pendingLoads.get(url.toString()); + if (pending) { + pending.abort(); + pendingLoads.delete(url.toString()); + } + } else if (event.data.type === 'preload') { + const urlstring = event.data.url; + const url = new URL(urlstring, event.origin); + immichAsset(url); + } +};