From 70fc2b08c67b5ad40391268443ddfc409f398fb8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Padez?=
Date: Wed, 2 Sep 2026 18:16:02 +0100
Subject: [PATCH] a shared link is a URL, not a key
Create, edit and revoke shared links from the album header, the selection
bar and the Sharing panel. Password, expiry, and the three flags that
decide what the person opening it can do.
The panel showed a bare key because this plugin never learned the
instance's public address. It does not need to store one: Immich has
`externalDomain` in its own admin settings, `/server/config` already
returns it, and it is the same field Immich's own copy-link button reads,
so the two cannot disagree. Empty is a real state and the UI says so
rather than inventing a host.
Payload shapes came from the live instance's spec.json, not from memory.
Co-Authored-By: Claude Opus 5 (1M context)
---
README.md | 13 ++
web/AlbumsSection.tsx | 17 ++-
web/SelectionBar.tsx | 21 ++-
web/ShareLinkDialog.tsx | 293 ++++++++++++++++++++++++++++++++++++++++
web/SharingSection.tsx | 187 ++++++++++++++++++++-----
web/shared.ts | 70 +++++++++-
web/useAssetActions.ts | 38 +++++-
7 files changed, 598 insertions(+), 41 deletions(-)
create mode 100644 web/ShareLinkDialog.tsx
diff --git a/README.md b/README.md
index 633b450..2cf3ba7 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,19 @@ your originals and every thumbnail stay exactly where they are.
- **More than one instance.** Accounts are a per-user registry with a label, a URL and a key; one is
active and you switch between them in the app.
+## Sharing
+
+Make a public link to an album, or to any selection of photos — from the album header, the selection bar,
+or the Sharing panel. A link can carry a password and an expiry, and you choose whether the people who open
+it may download originals, add photos of their own, or see where and when each shot was taken. Revoking one
+closes it for everybody holding it.
+
+The link opens on **your Immich**, not on Officer — that is the only way somebody with no Officer account
+can see it. So Officer needs to know your instance's public address, and it deliberately stores no copy of
+one: it reads **External Domain** from Immich's own admin settings, the same field Immich's own copy-link
+button uses, so the two can never disagree. Leave that field empty and the panel shows share keys instead of
+URLs, and says so.
+
## What you need
**An Immich you already run**, and an API key from it (Immich → Account Settings → API Keys).
diff --git a/web/AlbumsSection.tsx b/web/AlbumsSection.tsx
index a460aab..cc31708 100644
--- a/web/AlbumsSection.tsx
+++ b/web/AlbumsSection.tsx
@@ -1,6 +1,6 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
-import { ArrowLeft, ImageOff, MoreVertical, Plus } from 'lucide-react';
+import { ArrowLeft, ImageOff, MoreVertical, Plus, Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -12,6 +12,7 @@ import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { PhotoGrid } from './PhotoGrid';
import { SelectionBar } from './SelectionBar';
+import { ShareLinkDialog } from './ShareLinkDialog';
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
import { useAlbum, useAlbums } from './usePhotosData';
import { useAssetActions } from './useAssetActions';
@@ -123,6 +124,7 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
const selection = useSelection(assets.map((asset) => asset.id));
+ const [sharing, setSharing] = useState(false);
const back = (() => {
const next = new URLSearchParams(params);
@@ -150,6 +152,11 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
+
+ setSharing(true)} disabled={!album}>
+
+ Share
+
)}
@@ -167,6 +174,12 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
asset.id)} token={token} />
+
+
);
};
diff --git a/web/SelectionBar.tsx b/web/SelectionBar.tsx
index 407378c..404de39 100644
--- a/web/SelectionBar.tsx
+++ b/web/SelectionBar.tsx
@@ -1,5 +1,6 @@
import type { Selection } from './useSelection';
-import { Archive, ArchiveRestore, Heart, Plus, Trash2, Undo2, X } from 'lucide-react';
+import { useState } from 'react';
+import { Archive, ArchiveRestore, Heart, Plus, Share2, Trash2, Undo2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -8,6 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
+import { ShareLinkDialog } from './ShareLinkDialog';
import { useAlbums } from './usePhotosData';
import { useAssetActions } from './useAssetActions';
@@ -29,6 +31,12 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
useAssetActions();
const { data: albums } = useAlbums();
+ /*
+ The ids are captured when Share is pressed rather than read live, so the dialog keeps acting on what was
+ selected even if the selection changes behind it. Selection is deliberately NOT cleared: a link is usually
+ made to send, and losing the selection makes a second link over the same photos a re-selection job.
+ */
+ const [sharing, setSharing] = useState(null);
const run = (action: () => void) => {
action();
@@ -95,6 +103,11 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
)}
+ setSharing(ids)}>
+
+ Share
+
+
>
)}
+
+ !next && setSharing(null)}
+ target={sharing ? { kind: 'assets', assetIds: sharing } : null}
+ />
);
};
diff --git a/web/ShareLinkDialog.tsx b/web/ShareLinkDialog.tsx
new file mode 100644
index 0000000..1809ca9
--- /dev/null
+++ b/web/ShareLinkDialog.tsx
@@ -0,0 +1,293 @@
+import { useEffect, useState } from 'react';
+import { Copy, ExternalLink } from 'lucide-react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { copyToClipboard } from 'helpers/clipboard';
+import type { SharedLink } from './shared';
+import { shareUrl } from './shared';
+import { useAssetActions } from './useAssetActions';
+import { useServerConfig } from './usePhotosData';
+
+// Creating and editing one shared link.
+//
+// A shared link is the only thing /photos makes that anybody on the internet can open, so this form states
+// every consequence rather than relying on a default the owner cannot see. Immich's own defaults are
+// download-allowed, metadata-shown and no expiry; those are reproduced here as visible switches, not
+// inherited silently.
+//
+// After a successful create the dialog does NOT close. Making a link is never the goal — sending it is —
+// so it stays open holding the URL and a copy button. Closing on success would hand back a link the owner
+// then has to go and find.
+
+export type ShareTarget =
+ | { kind: 'album'; albumId: string; albumName: string }
+ | { kind: 'assets'; assetIds: string[] };
+
+type Props = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Create mode: what the new link points at. */
+ target?: ShareTarget | null;
+ /** Edit mode: the link being changed. Takes precedence over `target`. */
+ link?: SharedLink | null;
+};
+
+/** Offsets in minutes. `0` is "never", which Immich stores as a null expiry rather than a far-off date. */
+const EXPIRY_CHOICES = [
+ { value: '0', label: 'Never' },
+ { value: '30', label: '30 minutes' },
+ { value: '60', label: '1 hour' },
+ { value: '1440', label: '1 day' },
+ { value: '10080', label: '7 days' },
+ { value: '43200', label: '30 days' },
+ { value: '129600', label: '3 months' },
+ { value: '525600', label: '1 year' },
+] as const;
+
+const expiryFromChoice = (minutes: string): string | null =>
+ minutes === '0' ? null : new Date(Date.now() + Number(minutes) * 60_000).toISOString();
+
+export const ShareLinkDialog = ({ open, onOpenChange, target, link }: Props) => {
+ const editing = !!link;
+ const { createSharedLink, updateSharedLink } = useAssetActions();
+ const { data: config } = useServerConfig();
+
+ const [description, setDescription] = useState('');
+ const [password, setPassword] = useState('');
+ const [expiry, setExpiry] = useState('0');
+ const [allowDownload, setAllowDownload] = useState(true);
+ const [allowUpload, setAllowUpload] = useState(false);
+ const [showMetadata, setShowMetadata] = useState(true);
+ /** The link this dialog is currently showing a URL for — the one just created, or the one being edited. */
+ const [made, setMade] = useState(null);
+
+ // Reset on every open so a second link never inherits the first one's settings. An edit seeds from the
+ // link; a create starts from Immich's defaults, spelled out above.
+ useEffect(() => {
+ if (!open) return;
+ setDescription(link?.description ?? '');
+ setPassword(link?.password ?? '');
+ setExpiry('0');
+ setAllowDownload(link?.allowDownload ?? true);
+ setAllowUpload(link?.allowUpload ?? false);
+ setShowMetadata(link?.showMetadata ?? true);
+ setMade(link ?? null);
+ }, [open, link]);
+
+ const url = made ? shareUrl(config?.externalDomain, made) : null;
+
+ const submit = async () => {
+ const shared = {
+ description: description.trim() || null,
+ password: password.trim() || null,
+ allowDownload,
+ allowUpload,
+ showMetadata,
+ };
+
+ if (editing && link) {
+ // An untouched expiry select must not silently extend a link that already expires, so `expiresAt` is
+ // only sent when the owner actually picked something.
+ const result = await updateSharedLink.mutateAsync({
+ id: link.id,
+ ...shared,
+ ...(expiry === '0' ? {} : { expiresAt: expiryFromChoice(expiry) }),
+ });
+ setMade(result);
+ return;
+ }
+
+ if (!target) return;
+ const result = await createSharedLink.mutateAsync({
+ ...shared,
+ expiresAt: expiryFromChoice(expiry),
+ ...(target.kind === 'album'
+ ? { type: 'ALBUM' as const, albumId: target.albumId }
+ : { type: 'INDIVIDUAL' as const, assetIds: target.assetIds }),
+ });
+ setMade(result);
+ toast.success('Link created');
+ };
+
+ const what = editing
+ ? (link?.album?.albumName ?? link?.description ?? `${link?.assets.length ?? 0} items`)
+ : target?.kind === 'album'
+ ? target.albumName
+ : `${target?.assetIds.length ?? 0} items`;
+
+ const busy = createSharedLink.isPending || updateSharedLink.isPending;
+
+ return (
+
+
+
+ {editing ? 'Edit shared link' : 'Share'}
+ {what}
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="Optional — only you see this"
+ />
+
+
+
+ Password
+ setPassword(e.target.value)}
+ placeholder="Optional — anyone with the link must type this"
+ />
+
+
+
+
Expires
+
+
+
+
+
+ {EXPIRY_CHOICES.map((choice) => (
+
+ {choice.label}
+
+ ))}
+
+
+ {editing && link?.expiresAt && expiry === '0' && (
+
+ Currently expires {new Date(link.expiresAt).toLocaleString()}. Leave as Never to keep that date.
+
+ )}
+
+
+
+
+
+
+
+
+ {made && (
+
+
Link
+ {url ? (
+
+
e.target.select()} />
+
{
+ void copyToClipboard(url);
+ toast.success('Link copied');
+ }}
+ >
+
+
+
+
+
+
+
+
+ ) : (
+ /*
+ No externalDomain on the instance. The key is real and the link works — we simply cannot say
+ at which address, and guessing one would produce a URL that fails for whoever it was sent to.
+ */
+ <>
+
+
+ {
+ void copyToClipboard(made.key);
+ toast.success('Key copied');
+ }}
+ >
+
+
+
+
+ Immich has no public address set, so this is the key rather than a URL. Set{' '}
+ External Domain in Immich’s admin settings and
+ links will appear here in full.
+
+ >
+ )}
+
+ )}
+
+
+
+ onOpenChange(false)}>
+ {made ? 'Done' : 'Cancel'}
+
+ void submit()} disabled={busy}>
+ {editing ? 'Save' : made ? 'Create another' : 'Create link'}
+
+
+
+
+ );
+};
+
+const Toggle = ({
+ id,
+ label,
+ hint,
+ checked,
+ onChange,
+}: {
+ id: string;
+ label: string;
+ hint: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) => (
+
+);
diff --git a/web/SharingSection.tsx b/web/SharingSection.tsx
index 41c2bfb..b5db18a 100644
--- a/web/SharingSection.tsx
+++ b/web/SharingSection.tsx
@@ -1,29 +1,77 @@
+import { useState } from 'react';
import { Link } from 'react-router';
-import { Copy, ImageOff, Link2 } from 'lucide-react';
+import { Copy, ExternalLink, ImageOff, Link2, MoreVertical, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import { useClient } from 'hooks/useClient';
-import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
-import { useAlbums, useSharedLinks } from './usePhotosData';
import { copyToClipboard } from 'helpers/clipboard';
+import type { SharedLink } from './shared';
+import { ALBUM_PARAM, formatShortDate, photosSectionPath, shareUrl, thumbUrl } from './shared';
+import { ShareLinkDialog, type ShareTarget } from './ShareLinkDialog';
+import { useAlbums, useServerConfig, useSharedLinks } from './usePhotosData';
+import { useAssetActions } from './useAssetActions';
-// Sharing: shared albums and shared links, read-mostly.
+// Sharing: shared albums, and the links that make them public.
//
-// The link URL is Immich's own public URL, not an Officer route — a shared link is meant to be opened by
-// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
-// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
+// The link column shows a full URL, not a key. The address is Immich's own `externalDomain` read from
+// `/server/config` — this plugin still stores no public address of its own, which is what the previous
+// version of this file was protecting when it copied a bare key instead. The protection was never the
+// point; the missing address was, and Immich already knows it.
export const SharingSection = () => {
const { token } = useClient();
const { data: links, isLoading } = useSharedLinks();
const { data: albums } = useAlbums();
+ const { data: config } = useServerConfig();
+ const { deleteSharedLink } = useAssetActions();
+
+ const [target, setTarget] = useState(null);
+ const [editing, setEditing] = useState(null);
+ const [open, setOpen] = useState(false);
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
+ const share = (next: ShareTarget) => {
+ setEditing(null);
+ setTarget(next);
+ setOpen(true);
+ };
+
+ const edit = (link: SharedLink) => {
+ setTarget(null);
+ setEditing(link);
+ setOpen(true);
+ };
+
return (
Sharing
+
+
+
+
+
+ Share an album
+
+
+
+ {albums?.map((album) => (
+ share({ kind: 'album', albumId: album.id, albumName: album.albumName })}
+ >
+ {album.albumName}
+
+ ))}
+
+
@@ -66,36 +114,105 @@ export const SharingSection = () => {
No shared links.
)}
-
- {(links ?? []).map((link) => (
-
-
-
-
- {link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
-
-
- {link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
- {link.allowDownload ? ' · download' : ''}
- {link.allowUpload ? ' · upload' : ''}
-
-
- {
- void copyToClipboard(link.key);
- toast.success('Share key copied');
- }}
- >
-
-
-
- ))}
-
+ {!isLoading && (links ?? []).length > 0 && !config?.externalDomain && (
+ /*
+ Worth saying once, at the top, rather than per row: every link here works, and none of them can
+ be written as a URL until Immich knows the address it answers on.
+ */
+
+ Immich has no External Domain set, so these show as keys
+ rather than URLs. Set it in Immich’s admin settings.
+
+ )}
+
+ {(links ?? []).length > 0 && (
+
+ {(links ?? []).map((link) => (
+ edit(link)}
+ onRevoke={() => deleteSharedLink.mutate(link.id)}
+ />
+ ))}
+
+ )}
+
+
);
};
+
+const LinkRow = ({
+ link,
+ externalDomain,
+ onEdit,
+ onRevoke,
+}: {
+ link: SharedLink;
+ externalDomain: string | undefined;
+ onEdit: () => void;
+ onRevoke: () => void;
+}) => {
+ const url = shareUrl(externalDomain, link);
+ const expired = !!link.expiresAt && new Date(link.expiresAt).getTime() < Date.now();
+
+ return (
+
+
+
+
+ {link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
+
+
+ {link.expiresAt
+ ? `${expired ? 'Expired' : 'Expires'} ${formatShortDate(link.expiresAt)}`
+ : 'No expiry'}
+ {link.password ? ' · password' : ''}
+ {link.allowDownload ? ' · download' : ''}
+ {link.allowUpload ? ' · upload' : ''}
+ {link.showMetadata ? '' : ' · no metadata'}
+
+
+
+ {
+ void copyToClipboard(url ?? link.key);
+ toast.success(url ? 'Link copied' : 'Share key copied');
+ }}
+ >
+
+
+
+ {url && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ Edit
+ window.confirm('Revoke this link? Anyone holding it loses access.') && onRevoke()}
+ >
+ Revoke
+
+
+
+
+ );
+};
diff --git a/web/shared.ts b/web/shared.ts
index e14922a..e2a3abb 100644
--- a/web/shared.ts
+++ b/web/shared.ts
@@ -128,17 +128,74 @@ export type SearchResponse = {
export type ExploreItem = { value: string; data: Asset };
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
+export type SharedLinkType = 'ALBUM' | 'INDIVIDUAL';
+
+/**
+ * `GET /shared-links` — Immich's `SharedLinkResponseDto`.
+ *
+ * `password` really is returned in the clear. That is Immich's choice, not ours, and the edit form uses it:
+ * showing the owner the password they set beats making them reset it blind to find out what it was.
+ */
export type SharedLink = {
id: string;
key: string;
- type: string;
+ type: SharedLinkType;
album?: Album | null;
assets: Asset[];
description?: string | null;
+ password: string | null;
+ slug: string | null;
expiresAt: string | null;
createdAt: string;
allowDownload: boolean;
allowUpload: boolean;
+ showMetadata: boolean;
+};
+
+/**
+ * `POST /shared-links`. `type` decides which of the next two fields is read: `ALBUM` takes `albumId`,
+ * `INDIVIDUAL` takes `assetIds`.
+ */
+export type SharedLinkCreate = {
+ type: SharedLinkType;
+ albumId?: string;
+ assetIds?: string[];
+ description?: string | null;
+ password?: string | null;
+ expiresAt?: string | null;
+ allowUpload?: boolean;
+ allowDownload?: boolean;
+ showMetadata?: boolean;
+};
+
+/** `PATCH /shared-links/{id}`. Every field optional; omitting one leaves it as it was. */
+export type SharedLinkEdit = {
+ description?: string | null;
+ password?: string | null;
+ expiresAt?: string | null;
+ allowUpload?: boolean;
+ allowDownload?: boolean;
+ showMetadata?: boolean;
+};
+
+/**
+ * The URL to hand somebody, or null when Immich does not know its own public address.
+ *
+ * The host is Immich's `externalDomain`, not Officer's. A shared link is opened by someone with no Officer
+ * account, so it has to resolve to the instance itself — and reading it from `/server/config` means Officer
+ * stores no second copy that could disagree with the one Immich's own copy-link button uses.
+ *
+ * Empty is a real state: nobody has set it in Immich's admin settings yet, and the UI says exactly that
+ * instead of inventing a hostname. `/s/` when a slug is set, `/share/` otherwise — both are real
+ * routes on the instance, verified against 3.1.0.
+ */
+export const shareUrl = (
+ externalDomain: string | undefined,
+ link: Pick,
+): string | null => {
+ const base = externalDomain?.trim().replace(/\/+$/, '');
+ if (!base) return null;
+ return link.slug ? `${base}/s/${link.slug}` : `${base}/share/${link.key}`;
};
export type TimeBucket = { timeBucket: string; count: number };
@@ -153,7 +210,16 @@ export type MapMarker = { id: string; lat: number; lon: number; city: string | n
* Immich's web app gets it, and the only part of /photos that talks to anything other than Officer. It is not
* proxied because a vector basemap is thousands of tile requests that have nothing to do with the library.
*/
-export type ServerConfig = { mapDarkStyleUrl: string; mapLightStyleUrl: string; version?: string };
+export type ServerConfig = {
+ mapDarkStyleUrl: string;
+ mapLightStyleUrl: string;
+ version?: string;
+ /**
+ * Immich's own public address, from its admin settings — the one thing standing between a shared link and
+ * a URL somebody can open. See `shareUrl`.
+ */
+ externalDomain?: string;
+};
/**
* `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
diff --git a/web/useAssetActions.ts b/web/useAssetActions.ts
index ec48645..ad92018 100644
--- a/web/useAssetActions.ts
+++ b/web/useAssetActions.ts
@@ -1,4 +1,4 @@
-import type { Album } from './shared';
+import type { Album, SharedLink, SharedLinkCreate, SharedLinkEdit } from './shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
@@ -120,6 +120,39 @@ export function useAssetActions() {
onError: fail('rename the album'),
});
+ // ── Shared links ──
+ //
+ // The only thing in this file that produces a PUBLIC address, which is why the create dialog sends every
+ // flag explicitly instead of letting Immich's defaults apply. Immich defaults `allowDownload` and
+ // `showMetadata` to true and the expiry to never; a form that silently inherits those is a form that
+ // shares more than it appears to.
+
+ const createSharedLink = useMutation({
+ mutationFn: (body: SharedLinkCreate) => post('/photos/_officer/shared-links', body),
+ onSuccess: invalidate,
+ onError: fail('create the link'),
+ });
+
+ const updateSharedLink = useMutation({
+ mutationFn: ({ id, ...body }: SharedLinkEdit & { id: string }) =>
+ patch(`/photos/_officer/shared-links/${id}`, body),
+ onSuccess: () => {
+ invalidate();
+ toast.success('Link updated');
+ },
+ onError: fail('update the link'),
+ });
+
+ // Revoke, not delete: the link stops working for everybody holding it, and nothing about the photos changes.
+ const deleteSharedLink = useMutation({
+ mutationFn: (id: string) => del(`/photos/_officer/shared-links/${id}`),
+ onSuccess: () => {
+ invalidate();
+ toast.success('Link revoked');
+ },
+ onError: fail('revoke the link'),
+ });
+
return {
setFavorite,
setArchived,
@@ -132,5 +165,8 @@ export function useAssetActions() {
createAlbum,
deleteAlbum,
renameAlbum,
+ createSharedLink,
+ updateSharedLink,
+ deleteSharedLink,
};
}