Files
k8scd/immich/share-sync.yaml
T

307 lines
12 KiB
YAML

# Shares the Scouting MBG photos with every immich user. immich has no user
# groups (on its roadmap as "Soon"), so this does it per user:
# - albums that uploaders (kaderleden) create get the group account as
# editor, unless their name starts with "Privé" or "Prive";
# - every album the group account owns or edits gets every user, as editor
# when they may upload (quota other than 0, which authentik sets from
# Scouts Online) and as viewer otherwise;
# - the group account's library is shared as a partner and shown in
# everyone's timeline, so search covers it (immich only searches your own
# and your timeline partners' photos);
# - everyone is put in the group account's cluster group and gets its
# people (person sharing, immich 3.3): people are per user, and a partner
# library doesn't bring its people along. Those who may upload can name
# them, for everyone;
# - the invitation notifications for albums older than a week are hidden,
# or a new member would get one per album;
# - sessions of OAuth users older than SESSION_MAX_AGE_DAYS are ended, so
# someone who left the group loses access at the next login at Scouts
# Online.
# Albums and partners go through the API. Joining other people's albums
# (the API only lets an album's owner or editors share it), the timeline
# flag and the cluster group (which only the receiving user can set or
# accept), sharing the people (the API wants person.update, which the
# group's key lacks, and with password login off nobody can log in as the
# group to add it), the notifications and the sessions go straight to the
# database.
apiVersion: v1
kind: ConfigMap
metadata:
name: share-sync
namespace: immich
data:
sync.py: |
import collections
import json
import os
import subprocess
import urllib.request
API = os.environ["IMMICH_URL"].rstrip("/") + "/api"
KEY = os.environ["IMMICH_API_KEY"]
GROUP_EMAIL = os.environ["GROUP_EMAIL"]
SKIP_EMAILS = set(os.environ.get("SKIP_EMAILS", "").split())
SESSION_MAX_AGE_DAYS = int(os.environ.get("SESSION_MAX_AGE_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN") == "1"
def api(method, path, body=None):
req = urllib.request.Request(
API + path,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"x-api-key": KEY, "Accept": "application/json", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
return json.loads(data) if data else None
def sql(query, **params):
"""Runs query through psql; :'name' in it is params[name], quoted."""
args = ["psql", "-X", "-q", "-At", "-F", "\t", "-v", "ON_ERROR_STOP=1"]
for name, value in params.items():
args += ["-v", f"{name}={value}"]
out = subprocess.run(args, input=query, capture_output=True, text=True, check=True).stdout
return [line.split("\t") for line in out.splitlines() if line]
def change(what, call):
print(("would " if DRY_RUN else "") + what, flush=True)
if not DRY_RUN:
call()
users = sql('SELECT id, email, name, coalesce("quotaSizeInBytes"::text, \'\') FROM "user" WHERE "deletedAt" IS NULL')
group = next(u for u in users if u[1] == GROUP_EMAIL)
group_id, group_name = group[0], group[2]
members = {u[0]: u[1] for u in users if u[0] != group_id and u[1] not in SKIP_EMAILS}
# A quota of 0 bytes is how immich (and authentik's immich_quota claim)
# says: no uploads.
wanted = {u[0]: "viewer" if u[3] == "0" else "editor" for u in users if u[0] in members}
# Albums of uploaders that the group account isn't in yet
UNJOINED = """
FROM album_user au
JOIN "user" u ON u.id = au."userId"
JOIN album a ON a.id = au."albumId"
WHERE au.role = 'owner' AND u.id != :'group'
AND u."deletedAt" IS NULL AND a."deletedAt" IS NULL
AND (u."quotaSizeInBytes" IS NULL OR u."quotaSizeInBytes" > 0)
AND a."albumName" !~* '^\\s*priv[eé]'
AND NOT EXISTS (SELECT 1 FROM album_user g WHERE g."albumId" = a.id AND g."userId" = :'group')
"""
if DRY_RUN:
for (name,) in sql("SELECT a.\"albumName\"" + UNJOINED, group=group_id):
print(f"would join {name}", flush=True)
else:
for (name,) in sql(
f"""
WITH joined AS (
INSERT INTO album_user ("albumId", "userId", role)
SELECT a.id, :'group', 'editor' {UNJOINED}
RETURNING "albumId"
)
SELECT a."albumName" FROM joined JOIN album a ON a.id = joined."albumId"
""",
group=group_id,
):
print(f"join {name}", flush=True)
for album in api("GET", "/albums"):
roles = {au["user"]["id"]: au["role"] for au in album["albumUsers"]}
if roles.get(group_id) not in ("owner", "editor"):
continue
add = [{"userId": uid, "role": role} for uid, role in wanted.items() if uid not in roles]
if add:
change(
f"add {len(add)} users to {album['albumName']}",
lambda: api("PUT", f"/albums/{album['id']}/users", {"albumUsers": add}),
)
for uid, role in wanted.items():
if roles.get(uid) in ("viewer", "editor") and roles[uid] != role:
change(
f"make {members[uid]} {role} of {album['albumName']}",
lambda: api("PUT", f"/albums/{album['id']}/user/{uid}", {"role": role}),
)
partners = {p["id"] for p in api("GET", "/partners?direction=shared-by")}
for uid, email in members.items():
if uid not in partners:
change(f"share the library with {email}", lambda: api("POST", "/partners", {"sharedWithId": uid}))
if DRY_RUN:
raise SystemExit(0)
sql(
'UPDATE partner SET "inTimeline" = true WHERE "sharedById" = :\'group\' AND NOT "inTimeline"',
group=group_id,
)
# What accepting an invitation to the group account's cluster group does
# (ClusterGroupService.acceptRequest): the person groups only the member
# has people in move along. Members with people in groups that others
# also have people in are left alone; that only happens after sharing.
for (email,) in sql(
"""
BEGIN;
CREATE TEMP TABLE moving ON COMMIT DROP AS
SELECT u.id FROM "user" u, "user" g
WHERE g.id = :'group' AND u.id = ANY (:'members'::uuid[])
AND u."clusterGroupId" <> g."clusterGroupId"
AND NOT EXISTS (
SELECT 1 FROM person p
JOIN person o ON o."personGroupId" = p."personGroupId" AND o."ownerId" <> p."ownerId"
WHERE p."ownerId" = u.id
);
UPDATE person_group SET "clusterGroupId" = (SELECT "clusterGroupId" FROM "user" WHERE id = :'group')
WHERE id IN (SELECT p."personGroupId" FROM person p JOIN moving m ON m.id = p."ownerId");
DELETE FROM cluster_group_request
WHERE "userId" IN (SELECT id FROM moving)
AND "clusterGroupId" = (SELECT "clusterGroupId" FROM "user" WHERE id = :'group');
UPDATE "user" SET "clusterGroupId" = (SELECT "clusterGroupId" FROM "user" WHERE id = :'group')
WHERE id IN (SELECT id FROM moving)
RETURNING email;
COMMIT;
""",
group=group_id,
members="{" + ",".join(members) + "}",
):
print(f"put {email} in the group's cluster group", flush=True)
# What PUT /people/users does for the group account: its people, with
# their names, in every member's people list and search filters. Write
# access (naming people, for everyone) for those who may upload.
shared = sql(
"""
INSERT INTO person_user ("personGroupId", "sharedById", "sharedWithId", role)
SELECT p."personGroupId", p."ownerId", m.id, m.role::person_user_role_enum
FROM person p
JOIN "user" g ON g.id = p."ownerId"
CROSS JOIN unnest(:'ids'::uuid[], :'roles'::text[]) AS m(id, role)
JOIN "user" u ON u.id = m.id AND u."clusterGroupId" = g."clusterGroupId"
WHERE p."ownerId" = :'group' AND NOT p."isHidden"
AND NOT EXISTS (
SELECT 1 FROM person_user pu
WHERE pu."personGroupId" = p."personGroupId" AND pu."sharedById" = p."ownerId"
AND pu."sharedWithId" = m.id AND pu.role = m.role::person_user_role_enum
)
ON CONFLICT ("personGroupId", "sharedById", "sharedWithId") DO UPDATE SET role = EXCLUDED.role
RETURNING "sharedWithId", role
""",
group=group_id,
ids="{" + ",".join(wanted) + "}",
roles="{" + ",".join("read" if role == "viewer" else "write" for role in wanted.values()) + "}",
)
for (uid, role), count in sorted(collections.Counter(map(tuple, shared)).items()):
print(f"share {count} people with {members[uid]} ({role})", flush=True)
sql(
"""
UPDATE notification SET "deletedAt" = now()
WHERE type = 'AlbumInvite' AND "deletedAt" IS NULL AND "readAt" IS NULL
AND description LIKE :'sender' || ' shared an album%'
-- immich stores data as a JSON string inside the jsonb
AND ((data #>> '{}')::jsonb ->> 'albumId')::uuid IN (SELECT id FROM album WHERE "createdAt" < now() - interval '7 days')
""",
sender=group_name,
)
sql(
"""
DELETE FROM session
WHERE "createdAt" < now() - make_interval(days => :days)
AND "userId" IN (SELECT id FROM "user" WHERE "oauthId" IS NOT NULL)
""",
days=SESSION_MAX_AGE_DAYS,
)
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: immich-share
namespace: immich
spec:
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
refreshInterval: 15m
target:
name: immich-share
creationPolicy: Owner
data:
# API key of the group account, with album.read, albumUser.create,
# albumUser.update, partner.read and partner.create
- secretKey: api_key
remoteRef:
key: kv/immich-share
property: api_key
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: share-sync
namespace: immich
spec:
schedule: "*/10 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 26
containers:
- name: sync
# The database's own image: it has python3 and psql.
image: ghcr.io/tensorchord/cloudnative-vectorchord:17.11-1.1.1
command: ["python3", "/script/sync.py"]
env:
- name: IMMICH_URL
value: http://helm-immich-server:2283
- name: IMMICH_API_KEY
valueFrom:
secretKeyRef:
name: immich-share
key: api_key
- name: GROUP_EMAIL
value: fotoupload@scoutingmbg.nl
# The shared viewing account from before Scouts Online
- name: SKIP_EMAILS
value: foto@scoutingmbg.nl
- name: PGHOST
value: immich-postgres-17-rw
- name: PGSSLMODE
value: require
# album names are UTF-8 (and so is the Privé check)
- name: PGCLIENTENCODING
value: UTF8
- name: PGDATABASE
value: immich
- name: PGUSER
valueFrom:
secretKeyRef:
name: immich-postgres-17-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: immich-postgres-17-app
key: password
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumeMounts:
- name: script
mountPath: /script
volumes:
- name: script
configMap:
name: share-sync