mirror of
https://github.com/rubenhensen/k8scd.git
synced 2026-11-15 14:29:09 +01:00
307 lines
12 KiB
YAML
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
|