Files
k8scd/immich/share-sync.yaml
T
Ruben Hensen abed11dbaa immich: log in with Scouts Online only
foto.scoutingmbg.nl now goes straight to immich instead of through
sol-auth. immich starts the Scouts Online login right away, the password
login is off and the login page no longer shows the password of the
shared viewing account. share-sync is on, so everyone gets every album
and the group's library in their timeline.

sol-auth stays deployed for now, so switching the route back is the way
back.
2026-10-24 14:01:23 +02:00

208 lines
7.5 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:
# - 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);
# - 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; the timeline flag (which only
# the receiving user can set through the API), the notifications and the
# sessions go straight to the database.
apiVersion: v1
kind: ConfigMap
metadata:
name: share-sync
namespace: immich
data:
sync.py: |
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}
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,
)
sql(
"""
UPDATE notification SET "deletedAt" = now()
WHERE type = 'AlbumInvite' AND "deletedAt" IS NULL AND "readAt" IS NULL
AND description LIKE :'sender' || ' shared an album%'
AND (data->>'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
- 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