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,
};
}