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);
+ }
+};