mirror of
https://github.com/rubenhensen/k8scd.git
synced 2026-11-15 22:29:09 +01:00
Nobody uses the group account anymore, so new albums belong to the leiding who make them, and share-sync only shared albums the group account was in. It now first adds the group account as editor to every album an uploader owns (straight in the database: the API only lets an album's owner or editors share it), after which the album goes to everyone like the others. Albums whose name starts with "Privé" or "Prive" are left alone.
243 lines
9.0 KiB
YAML
243 lines
9.0 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);
|
|
# - 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 (which only the receiving user can set), 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}
|
|
|
|
# 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,
|
|
)
|
|
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
|