initial commit

This commit is contained in:
2026-08-04 21:02:28 +02:00
parent fc1bbe2a85
commit d14690c525
27 changed files with 1810 additions and 0 deletions
View File
+42
View File
@@ -0,0 +1,42 @@
"""Authelia-Integration: liest den vom Reverse-Proxy weitergereichten
Remote-User-Header und mappt ihn per accounts.json auf einen internen
Account-Namen. Kein eigenes Login, Authelia übernimmt die eigentliche
Authentifizierung vorgeschaltet."""
from fastapi import Header, HTTPException, status
from config import Config
def get_current_user(remote_user: str | None = Header(default=None, alias="Remote-User")) -> str:
"""Extrahiert den Authelia-Benutzernamen aus dem konfigurierten Header.
Der Header-Name ist über AUTH_REMOTE_USER_HEADER konfigurierbar, FastAPI
bindet hier auf den Default 'Remote-User', siehe Hinweis in README.md
falls du einen anderen Header-Namen in Authelia/nginx konfiguriert hast."""
if not remote_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Kein Remote-User-Header vom Reverse-Proxy erhalten. "
"Läuft die Anwendung hinter Authelia mit korrektem auth_request-Setup?",
)
return remote_user
def resolve_account_for_user(authelia_user: str) -> tuple[str | None, bool]:
"""Gibt (account_name, is_admin) zurück.
account_name ist None, wenn der User als Admin auf ALLE Accounts zugreifen darf.
Ist der User weder gemappt noch Admin, wird eine 403 geworfen."""
admins = Config.load_admin_users()
user_map = Config.load_authelia_user_map()
is_admin = authelia_user in admins
if is_admin:
return None, True
account_name = user_map.get(authelia_user)
if not account_name:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Authelia-Benutzer '{authelia_user}' ist keinem Account in accounts.json zugeordnet.",
)
return account_name, False
+176
View File
@@ -0,0 +1,176 @@
"""FastAPI-App für die interne Web-Ansicht/API der iCloud-Kontakte.
Läuft im selben Image wie der Sync-Container, wird aber über einen
eigenen Docker-Compose-Service mit abweichendem Startbefehl gestartet
(uvicorn statt sync/mailer). Zugriff ausschließlich über einen
vorgeschalteten Reverse-Proxy mit Authelia, der den eingeloggten
Benutzernamen im Remote-User-Header mitschickt."""
import json
import logging
from datetime import date
from fastapi import FastAPI, Depends, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from config import Config
import db
from api.auth import get_current_user, resolve_account_for_user
from api.schemas import ContactListResponse, ContactOut, SyncRunOut
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("api")
app = FastAPI(title="iCloud Contacts Sync Interne API", version="1.0.0")
templates = Jinja2Templates(directory="api/templates")
def _row_to_contact_out(row: dict) -> dict:
row = dict(row)
for field in ["emails", "phones", "addresses", "urls", "categories"]:
raw = row.get(field)
row[field] = json.loads(raw) if raw else []
row["updated_at"] = str(row["updated_at"])
return row
def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
if account_name is None:
return "", []
return "WHERE account = %s", [account_name]
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/contacts", response_model=ContactListResponse)
def list_contacts(
request: Request,
q: str | None = Query(default=None, description="Freitextsuche über Name, Organisation, E-Mail"),
limit: int = Query(default=50, le=200),
offset: int = Query(default=0, ge=0),
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
search_clause = ""
if q:
search_op = "AND" if where_clause else "WHERE"
search_clause = f" {search_op} (full_name LIKE %s OR organization LIKE %s OR emails LIKE %s)"
like = f"%{q}%"
params.extend([like, like, like])
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM contacts {where_clause}{search_clause}", params)
total = cur.fetchone()["total"]
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at
FROM contacts {where_clause}{search_clause}
ORDER BY full_name
LIMIT %s OFFSET %s""",
params + [limit, offset],
)
rows = cur.fetchall()
items = [_row_to_contact_out(r) for r in rows]
return {"total": total, "items": items}
@app.get("/api/contacts/{contact_id}", response_model=ContactOut)
def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
id_clause = "AND id = %s" if where_clause else "WHERE id = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at
FROM contacts {where_clause} {id_clause}""",
params + [contact_id],
)
row = cur.fetchone()
if not row:
return {}
return _row_to_contact_out(row)
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
def birthdays_today(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
today = date.today()
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
month_day_clause = "AND MONTH(birthday) = %s AND DAY(birthday) = %s" if where_clause \
else "WHERE MONTH(birthday) = %s AND DAY(birthday) = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at
FROM contacts {where_clause} {month_day_clause}
ORDER BY full_name""",
params + [today.month, today.day],
)
rows = cur.fetchall()
return [_row_to_contact_out(r) for r in rows]
@app.get("/api/sync-runs", response_model=list[SyncRunOut])
def list_sync_runs(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, sync_type, started_at, finished_at, status,
contacts_upserted, contacts_deleted, error_message
FROM sync_runs {where_clause}
ORDER BY started_at DESC
LIMIT 50""",
params,
)
rows = cur.fetchall()
for r in rows:
r["started_at"] = str(r["started_at"])
r["finished_at"] = str(r["finished_at"]) if r["finished_at"] else None
return rows
@app.get("/", response_class=HTMLResponse)
def web_index(request: Request, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, full_name, organization, birthday, account
FROM contacts {where_clause}
ORDER BY full_name
LIMIT 200""",
params,
)
rows = cur.fetchall()
return templates.TemplateResponse(
"index.html",
{
"request": request,
"current_user": current_user,
"is_admin": is_admin,
"account_name": account_name or "alle Accounts",
"contacts": rows,
},
)
+41
View File
@@ -0,0 +1,41 @@
from datetime import date
from pydantic import BaseModel
class ContactOut(BaseModel):
id: int
account: str
uid: str
full_name: str | None
given_name: str | None
family_name: str | None
organization: str | None
job_title: str | None
birthday: date | None
notes: str | None
emails: list
phones: list
addresses: list
urls: list
categories: list
updated_at: str
class Config:
from_attributes = True
class ContactListResponse(BaseModel):
total: int
items: list[ContactOut]
class SyncRunOut(BaseModel):
id: str
account: str
sync_type: str
started_at: str
finished_at: str | None
status: str
contacts_upserted: int | None
contacts_deleted: int | None
error_message: str | None
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Kontakte {{ account_name }}</title>
<style>
body { font-family: sans-serif; margin: 2rem; background: #111; color: #eee; }
h1 { font-size: 1.4rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border-bottom: 1px solid #333; padding: 0.4rem 0.6rem; text-align: left; }
th { background: #1c1c1c; }
.meta { color: #999; font-size: 0.85rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>Kontakte</h1>
<div class="meta">
Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %}
· {{ contacts|length }} Kontakte (max. 200 angezeigt)
</div>
<table>
<thead>
<tr><th>Name</th><th>Organisation</th><th>Geburtstag</th>{% if is_admin %}<th>Account</th>{% endif %}</tr>
</thead>
<tbody>
{% for c in contacts %}
<tr>
<td>{{ c.full_name or "-" }}</td>
<td>{{ c.organization or "-" }}</td>
<td>{{ c.birthday or "-" }}</td>
{% if is_admin %}<td>{{ c.account }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>