mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-17 23:40:24 +00:00
initial commit
This commit is contained in:
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user