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}` : ''}

+
+ )} @@ -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 )} + + + + + ) : ( + /* + 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. + */ + <> +
+ + +
+

+ 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. +

+ + )} + + )} + + + + + + + + + ); +}; + +const Toggle = ({ + id, + label, + hint, + checked, + onChange, +}: { + id: string; + label: string; + hint: string; + checked: boolean; + onChange: (value: boolean) => void; +}) => ( +
+
+ +

{hint}

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

+
+ + + + + + {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' : ''} -

    -
    - -
  • - ))} -
+ {!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'} +

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