Compare commits
21 Commits
master
...
b8a578cf53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a578cf53 | ||
|
|
572172c424 | ||
|
|
343f7a5e7b | ||
|
|
cad48efcc6 | ||
|
|
2ae8499247 | ||
|
|
79fdf4f54a | ||
|
|
f76d2783d9 | ||
|
|
e539508bec | ||
|
|
784c9013eb | ||
|
|
e3844294ae | ||
|
|
444772c959 | ||
|
|
e0ea16f2ef | ||
|
|
9fb350eb29 | ||
|
|
94655ce7c5 | ||
|
|
9306d638ad | ||
|
|
f662163185 | ||
|
|
fb32f0424f | ||
|
|
34e701fa78 | ||
|
|
661a8ea579 | ||
|
|
47944ed952 | ||
|
|
a591eb51d3 |
63
backend/dav_util.py
Normal file
63
backend/dav_util.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Shared helpers for CalDAV publishing of local calendars.
|
||||
|
||||
Publishing is opt-in per calendar: a published calendar gets a secret
|
||||
``dav_token`` and is reachable as a two-way CalDAV collection at
|
||||
``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect
|
||||
changes; each event carries an ``etag`` that changes on write. Rotating the
|
||||
token revokes existing subscriptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
"""A URL-safe, unguessable token used as the CalDAV collection path."""
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def new_tag() -> str:
|
||||
"""A fresh ctag/etag value."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def bump_dav(cal, event=None) -> None:
|
||||
"""Mark a calendar (and optionally an event) as changed for CalDAV clients.
|
||||
|
||||
Safe to call unconditionally on every local-event write — it only refreshes
|
||||
opaque change tags, so unpublished calendars are unaffected.
|
||||
"""
|
||||
if cal is not None:
|
||||
cal.dav_ctag = new_tag()
|
||||
if event is not None:
|
||||
event.etag = new_tag()
|
||||
|
||||
|
||||
def public_base(request) -> str:
|
||||
"""Public origin (scheme://host) as clients actually reach us.
|
||||
|
||||
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
|
||||
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
|
||||
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
|
||||
ones.
|
||||
"""
|
||||
env = os.environ.get("PUBLIC_BASE_URL")
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
h = request.headers
|
||||
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
|
||||
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def caldav_url(request, token: str) -> str:
|
||||
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
|
||||
return f"{public_base(request)}/dav/{token}/"
|
||||
|
||||
|
||||
def caldav_login_url(request) -> str:
|
||||
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
|
||||
return f"{public_base(request)}/caldav/"
|
||||
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
from routers import auth_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -225,6 +225,40 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
||||
for col, ddl in (
|
||||
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
||||
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
|
||||
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_calendars", col)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added etag to local_events")
|
||||
except Exception:
|
||||
pass
|
||||
# Per-recipient colour override for a shared calendar (NULL = owner's colour).
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added color to calendar_shares")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hide a user from sharing/group pickers (admin management still shows them).
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added directory_hidden to users")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||
@@ -272,6 +306,9 @@ app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
|
||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||
app.include_router(dav_router.router, tags=["dav"])
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
@@ -17,6 +17,9 @@ class User(Base):
|
||||
avatar_filename = Column(String(255), nullable=True)
|
||||
totp_secret = Column(String(32), nullable=True)
|
||||
totp_enabled = Column(Boolean, default=False)
|
||||
# When true, the user is hidden from sharing/group picker directories
|
||||
# (/users/directory). Admin user management (/users/) still shows them.
|
||||
directory_hidden = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
caldav_accounts = relationship(
|
||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
@@ -114,6 +117,26 @@ class UserSettings(Base):
|
||||
user = relationship("User", back_populates="settings")
|
||||
|
||||
|
||||
class AppPassword(Base):
|
||||
"""Per-device app-specific password for CalDAV (Basic Auth).
|
||||
|
||||
Keeps MFA intact: accounts with 2FA can't use their normal password over
|
||||
CalDAV (clients can't send a TOTP code), so they authenticate with one of
|
||||
these revocable app passwords instead. Only the bcrypt hash is stored.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_passwords"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
label = Column(String(100), nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(String(50), nullable=True)
|
||||
last_used_at = Column(String(50), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class LocalCalendar(Base):
|
||||
__tablename__ = "local_calendars"
|
||||
|
||||
@@ -124,6 +147,12 @@ class LocalCalendar(Base):
|
||||
enabled = Column(Boolean, default=True)
|
||||
# Whether events of this calendar generate reminders/notifications on clients.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
|
||||
# collection reachable via a secret token URL. dav_ctag changes on every
|
||||
# event write so clients detect changes; rotating the token revokes access.
|
||||
caldav_published = Column(Boolean, default=False, nullable=False)
|
||||
dav_token = Column(String(64), nullable=True, unique=True)
|
||||
dav_ctag = Column(String(32), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="local_calendars")
|
||||
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||
@@ -152,6 +181,8 @@ class LocalEvent(Base):
|
||||
creator_name_external = Column(Text, nullable=True)
|
||||
# Private events are filtered for other group members per their visibility setting.
|
||||
is_private = Column(Boolean, default=False)
|
||||
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
|
||||
etag = Column(String(32), nullable=True)
|
||||
|
||||
calendar = relationship("LocalCalendar", back_populates="events")
|
||||
creator = relationship("User")
|
||||
@@ -276,6 +307,7 @@ class CalendarShare(Base):
|
||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
permission = Column(String(20), default="read") # 'read' | 'read_write'
|
||||
color = Column(String(16), nullable=True) # recipient's own colour; NULL = owner's
|
||||
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||
|
||||
calendar = relationship("LocalCalendar")
|
||||
|
||||
@@ -97,8 +97,44 @@ def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> model
|
||||
return cal
|
||||
|
||||
|
||||
def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]:
|
||||
"""Calendars that co-members of the user's groups share into the group.
|
||||
|
||||
Each user designates ONE of their own calendars via
|
||||
UserSettings.group_visible_calendar_id. This returns those calendars for
|
||||
every co-member of any group the user belongs to (excluding the user's own).
|
||||
The calendar must be owned by the designating member. Deduped (each calendar
|
||||
appears once even across multiple shared groups).
|
||||
"""
|
||||
my_group_ids = (
|
||||
db.query(models.GroupMember.group_id)
|
||||
.filter(models.GroupMember.user_id == user.id)
|
||||
)
|
||||
co_member_ids = (
|
||||
db.query(models.GroupMember.user_id)
|
||||
.filter(
|
||||
models.GroupMember.group_id.in_(my_group_ids),
|
||||
models.GroupMember.user_id != user.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.join(
|
||||
models.UserSettings,
|
||||
models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id,
|
||||
)
|
||||
.filter(
|
||||
models.UserSettings.user_id.in_(co_member_ids),
|
||||
# the designating member must own the calendar they share
|
||||
models.LocalCalendar.user_id == models.UserSettings.user_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
"""All local calendar ids the user may read: own + shared + group calendars."""
|
||||
"""All local calendar ids the user may read: own + shared + group + co-member group-visible."""
|
||||
ids: set[int] = set()
|
||||
|
||||
own = (
|
||||
@@ -123,4 +159,7 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
)
|
||||
ids.update(r[0] for r in group_cals)
|
||||
|
||||
# Calendars co-members share into shared groups (group-visible).
|
||||
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
|
||||
|
||||
return list(ids)
|
||||
|
||||
@@ -300,6 +300,7 @@ def get_events(
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
all_events = []
|
||||
sync_errors = []
|
||||
accounts = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(
|
||||
@@ -333,6 +334,12 @@ def get_events(
|
||||
logger.error(
|
||||
"Error fetching calendar %s: %s", calendar.id, exc
|
||||
)
|
||||
sync_errors.append({
|
||||
"source": "caldav",
|
||||
"name": f"{account.username} – {calendar.name}",
|
||||
"calendar_id": calendar.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
# ── Local calendar events (own + shared + group calendars) ─────────────
|
||||
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
|
||||
@@ -345,6 +352,21 @@ def get_events(
|
||||
.all()
|
||||
) if readable_ids else []
|
||||
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
||||
# A personal calendar shared WITH the current user is relabelled under the
|
||||
# owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and
|
||||
# flagged read_only unless the share grants write. Group calendars are
|
||||
# excluded — they keep their own name and stay writable for members.
|
||||
shares_by_cal = {
|
||||
s.calendar_id: s
|
||||
for s in db.query(models.CalendarShare).filter(
|
||||
models.CalendarShare.user_id == current_user.id
|
||||
)
|
||||
}
|
||||
group_cal_ids = {
|
||||
r[0] for r in db.query(models.GroupCalendar.calendar_id).filter(
|
||||
models.GroupCalendar.calendar_id.in_(readable_ids)
|
||||
)
|
||||
} if readable_ids else set()
|
||||
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||
vis_cache: dict = {}
|
||||
|
||||
@@ -354,6 +376,15 @@ def get_events(
|
||||
return vis_cache[uid]
|
||||
|
||||
for local_cal in local_calendars:
|
||||
# Decoration for a personal calendar shared with (not owned by) me.
|
||||
is_shared_personal = (
|
||||
local_cal.user_id != current_user.id
|
||||
and local_cal.id not in group_cal_ids
|
||||
)
|
||||
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
|
||||
shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None
|
||||
shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write"
|
||||
shared_color = share.color if share else None
|
||||
local_events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
@@ -384,6 +415,12 @@ def get_events(
|
||||
else:
|
||||
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
|
||||
for b in built:
|
||||
if shared_owner_name:
|
||||
b["calendar_name"] = shared_owner_name
|
||||
if shared_read_only:
|
||||
b["read_only"] = True
|
||||
if shared_color:
|
||||
b["calendarColor"] = shared_color
|
||||
b = apply_event_privacy(
|
||||
b, owner_id=owner_id, is_private=is_priv,
|
||||
requester_id=current_user.id, visibility=visibility,
|
||||
@@ -414,13 +451,18 @@ def get_events(
|
||||
.filter(models.GoogleAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
google_errors = []
|
||||
for g_acc in google_accounts:
|
||||
try:
|
||||
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
|
||||
g_events, g_errors = get_google_events(g_acc, start_dt, end_dt, db)
|
||||
all_events.extend(g_events)
|
||||
sync_errors.extend(g_errors)
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
|
||||
google_errors.append({"email": g_acc.email})
|
||||
sync_errors.append({
|
||||
"source": "google",
|
||||
"name": g_acc.email,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
# ── Home Assistant events ─────────────────────────────
|
||||
from routers.homeassistant_router import get_ha_events
|
||||
@@ -431,11 +473,18 @@ def get_events(
|
||||
)
|
||||
for ha_acc in ha_accounts:
|
||||
try:
|
||||
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
|
||||
ha_events, ha_errors = get_ha_events(ha_acc, start_dt, end_dt, db)
|
||||
all_events.extend(ha_events)
|
||||
sync_errors.extend(ha_errors)
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
||||
sync_errors.append({
|
||||
"source": "homeassistant",
|
||||
"name": ha_acc.name,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
return {"events": all_events, "errors": google_errors}
|
||||
return {"events": all_events, "errors": sync_errors}
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
|
||||
491
backend/routers/dav_router.py
Normal file
491
backend/routers/dav_router.py
Normal file
@@ -0,0 +1,491 @@
|
||||
"""Minimal two-way CalDAV server for published local calendars.
|
||||
|
||||
Two ways to reach a published local calendar:
|
||||
|
||||
1. Secret token URL (no login) — ``/dav/{token}/``.
|
||||
2. Username + password (HTTP Basic Auth) with principal discovery —
|
||||
``/caldav/`` advertises the user's calendar-home-set and lists every
|
||||
published calendar as ``/caldav/{id}/``. This is what account-based clients
|
||||
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
|
||||
|
||||
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
|
||||
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
|
||||
getetag per event), avoiding deletion tombstones.
|
||||
|
||||
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
|
||||
round-tripped (parse_ics ignores them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote, unquote
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import dav_util
|
||||
import ical_io
|
||||
import models
|
||||
from auth import verify_password
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# XML namespaces used across WebDAV / CalDAV.
|
||||
NS_DAV = "DAV:"
|
||||
NS_CAL = "urn:ietf:params:xml:ns:caldav"
|
||||
NS_CS = "http://calendarserver.org/ns/"
|
||||
NS_ICAL = "http://apple.com/ns/ical/"
|
||||
|
||||
_NS_DECL = (
|
||||
'xmlns:D="DAV:" '
|
||||
'xmlns:C="urn:ietf:params:xml:ns:caldav" '
|
||||
'xmlns:CS="http://calendarserver.org/ns/" '
|
||||
'xmlns:ICAL="http://apple.com/ns/ical/"'
|
||||
)
|
||||
|
||||
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
|
||||
_MULTISTATUS_CT = "application/xml; charset=utf-8"
|
||||
_LOGIN_HREF = "/caldav/"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
|
||||
if not token:
|
||||
return None
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.dav_token == token,
|
||||
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
|
||||
"""Validate an HTTP Basic Authorization header against a Calendarr account.
|
||||
|
||||
Accepts an app-specific password (always) or the account password (only when
|
||||
MFA is off — otherwise the account password would bypass 2FA, which CalDAV
|
||||
clients can't satisfy).
|
||||
"""
|
||||
hdr = request.headers.get("Authorization", "")
|
||||
if not hdr.lower().startswith("basic "):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
username, sep, password = raw.partition(":")
|
||||
if not sep:
|
||||
return None
|
||||
# Login names are stored lowercase; match case-insensitively like the web login.
|
||||
user = (
|
||||
db.query(models.User)
|
||||
.filter(func.lower(models.User.username) == username.lower())
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# 1) App-specific passwords — always allowed, MFA-safe.
|
||||
for ap in db.query(models.AppPassword).filter(models.AppPassword.user_id == user.id).all():
|
||||
try:
|
||||
if verify_password(password, ap.password_hash):
|
||||
ap.last_used_at = datetime.now(timezone.utc).isoformat()
|
||||
db.commit()
|
||||
return user
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 2) Account password — only when 2FA is disabled.
|
||||
if not user.totp_enabled:
|
||||
try:
|
||||
if verify_password(password, user.password_hash):
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _unauthorized() -> Response:
|
||||
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
|
||||
|
||||
|
||||
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == user.id,
|
||||
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
|
||||
return (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _etag(ev: models.LocalEvent) -> str:
|
||||
return ev.etag or "0"
|
||||
|
||||
|
||||
def _resource_name(ev: models.LocalEvent) -> str:
|
||||
return f"{quote(ev.uid, safe='')}.ics"
|
||||
|
||||
|
||||
def _event_href(base: str, ev: models.LocalEvent) -> str:
|
||||
return f"{base}{_resource_name(ev)}"
|
||||
|
||||
|
||||
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
if owner:
|
||||
return {owner.id: (owner.display_name or owner.username)}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
|
||||
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
|
||||
|
||||
|
||||
# ── XML builders ──────────────────────────────────────────
|
||||
|
||||
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
|
||||
principal_href: str | None = None,
|
||||
home_href: str | None = None) -> str:
|
||||
principal_href = principal_href or base
|
||||
home_href = home_href or base
|
||||
return f""" <D:response>
|
||||
<D:href>{base}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
|
||||
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
|
||||
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
|
||||
<D:supported-report-set>
|
||||
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
|
||||
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
|
||||
</D:supported-report-set>
|
||||
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
|
||||
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
|
||||
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
|
||||
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
|
||||
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"""
|
||||
|
||||
|
||||
def _principal_propstat(user: models.User) -> str:
|
||||
href = _LOGIN_HREF
|
||||
name = user.display_name or user.username
|
||||
return f""" <D:response>
|
||||
<D:href>{href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
|
||||
<D:displayname>{xml_escape(name)}</D:displayname>
|
||||
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
|
||||
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
|
||||
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"""
|
||||
|
||||
|
||||
def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
|
||||
ics: str | None = None) -> str:
|
||||
href = _event_href(base, ev)
|
||||
data = ""
|
||||
if with_data and ics is not None:
|
||||
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
|
||||
return f""" <D:response>
|
||||
<D:href>{href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
|
||||
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"""
|
||||
|
||||
|
||||
def _multistatus(body: str) -> Response:
|
||||
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
|
||||
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
|
||||
|
||||
|
||||
# ── Method handlers (shared by token and Basic-Auth paths) ──
|
||||
|
||||
def _handle_options() -> Response:
|
||||
return Response(status_code=200, headers={
|
||||
"DAV": "1, 2, 3, calendar-access",
|
||||
"Allow": _ALLOW,
|
||||
})
|
||||
|
||||
|
||||
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
|
||||
name = resource.rsplit("/", 1)[-1]
|
||||
if name.endswith(".ics"):
|
||||
name = name[:-4]
|
||||
uid = unquote(name)
|
||||
return (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
models.LocalEvent.uid == uid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
|
||||
db: Session, *, principal_href: str | None = None,
|
||||
home_href: str | None = None) -> Response:
|
||||
# PROPFIND on a single event resource.
|
||||
if resource:
|
||||
ev = _find_event(cal, resource, db)
|
||||
if not ev:
|
||||
return Response(status_code=404)
|
||||
return _multistatus(_event_propstat(base, ev))
|
||||
|
||||
# Collection: always include collection props; Depth:1 adds each event.
|
||||
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
|
||||
if depth != "0":
|
||||
for ev in _events(cal, db):
|
||||
parts.append(_event_propstat(base, ev))
|
||||
return _multistatus("\n".join(parts))
|
||||
|
||||
|
||||
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
|
||||
report_type = None
|
||||
hrefs: list[str] = []
|
||||
if body:
|
||||
try:
|
||||
root = ET.fromstring(body)
|
||||
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
|
||||
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
|
||||
except ET.ParseError:
|
||||
pass
|
||||
|
||||
if report_type == "calendar-multiget" and hrefs:
|
||||
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
|
||||
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
|
||||
else:
|
||||
# calendar-query (or unknown) → return the whole calendar.
|
||||
evs = _events(cal, db)
|
||||
|
||||
parts = []
|
||||
for ev in evs:
|
||||
ics = _build_ics(cal, [ev], db)
|
||||
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
|
||||
return _multistatus("\n".join(parts) if parts else "")
|
||||
|
||||
|
||||
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
|
||||
*, head: bool = False) -> Response:
|
||||
ev = _find_event(cal, resource, db)
|
||||
if not ev:
|
||||
return Response(status_code=404)
|
||||
ics = _build_ics(cal, [ev], db)
|
||||
headers = {"ETag": f'"{_etag(ev)}"'}
|
||||
return Response(
|
||||
content=b"" if head else ics,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response:
|
||||
try:
|
||||
parsed = ical_io.parse_ics(body)
|
||||
except ValueError:
|
||||
return Response(status_code=400)
|
||||
items = parsed.get("events") or []
|
||||
if not items:
|
||||
return Response(status_code=400)
|
||||
item = items[0]
|
||||
|
||||
# Key by the VEVENT UID; fall back to the resource name.
|
||||
uid = item.get("uid")
|
||||
if not uid:
|
||||
name = resource.rsplit("/", 1)[-1]
|
||||
uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4())
|
||||
|
||||
# Scope to THIS calendar — never touch another calendar's/user's event that
|
||||
# happens to share the UID (local_events.uid is globally unique).
|
||||
ev = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
models.LocalEvent.uid == uid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
created = ev is None
|
||||
if created:
|
||||
# If the UID already exists elsewhere, the global UNIQUE constraint would
|
||||
# 500 on commit — reject cleanly with 409 instead.
|
||||
if db.query(models.LocalEvent.id).filter(models.LocalEvent.uid == uid).first():
|
||||
return Response(status_code=409)
|
||||
ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id)
|
||||
db.add(ev)
|
||||
ev.title = item.get("title") or "(ohne Titel)"
|
||||
ev.start = item["start"]
|
||||
ev.end = item["end"]
|
||||
ev.all_day = item.get("all_day", False)
|
||||
ev.location = item.get("location")
|
||||
ev.description = item.get("description")
|
||||
ev.rrule = item.get("rrule")
|
||||
ev.exdate = item.get("exdate")
|
||||
dav_util.bump_dav(cal, ev)
|
||||
db.commit()
|
||||
db.refresh(ev)
|
||||
return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'})
|
||||
|
||||
|
||||
def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response:
|
||||
ev = _find_event(cal, resource, db)
|
||||
if not ev:
|
||||
return Response(status_code=404)
|
||||
dav_util.bump_dav(cal)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
|
||||
resource: str, db: Session, *,
|
||||
principal_href: str | None = None,
|
||||
home_href: str | None = None) -> Response:
|
||||
"""Serve a single calendar collection; auth/ownership already checked."""
|
||||
method = request.method.upper()
|
||||
if method == "PROPFIND":
|
||||
depth = request.headers.get("Depth", "0")
|
||||
return _handle_propfind(cal, base, resource, depth, db,
|
||||
principal_href=principal_href, home_href=home_href)
|
||||
if method == "REPORT":
|
||||
return _handle_report(cal, base, await request.body(), db)
|
||||
if method in ("GET", "HEAD"):
|
||||
if not resource:
|
||||
ics = _build_ics(cal, _events(cal, db), db)
|
||||
return Response(content=ics, media_type="text/calendar; charset=utf-8")
|
||||
return _handle_get(cal, resource, db, head=(method == "HEAD"))
|
||||
if method == "PUT":
|
||||
return _handle_put(cal, resource, await request.body(), db)
|
||||
if method == "DELETE":
|
||||
if not resource:
|
||||
return Response(status_code=403) # don't delete the collection itself
|
||||
return _handle_delete(cal, resource, db)
|
||||
return Response(status_code=405, headers={"Allow": _ALLOW})
|
||||
|
||||
|
||||
# ── Token path (no login): /dav/{token}/… ─────────────────
|
||||
|
||||
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
cal = _resolve(token, db)
|
||||
if not cal:
|
||||
return Response(status_code=404)
|
||||
base = f"/dav/{token}/"
|
||||
return await _dispatch_collection(request, cal, base, resource, db)
|
||||
|
||||
|
||||
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
|
||||
|
||||
|
||||
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
|
||||
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_token(request, token, "", db)
|
||||
|
||||
|
||||
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False)
|
||||
async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_token(request, token, resource, db)
|
||||
|
||||
|
||||
# ── Basic-Auth path (username/password): /caldav/… ────────
|
||||
|
||||
async def _dispatch_home(request: Request, db: Session) -> Response:
|
||||
"""Principal + calendar-home-set: lists the user's published calendars."""
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
user = _basic_auth_user(request, db)
|
||||
if not user:
|
||||
return _unauthorized()
|
||||
if request.method.upper() != "PROPFIND":
|
||||
return Response(status_code=405, headers={"Allow": _ALLOW})
|
||||
depth = request.headers.get("Depth", "0")
|
||||
parts = [_principal_propstat(user)]
|
||||
if depth != "0":
|
||||
for cal in _published_calendars(user, db):
|
||||
parts.append(_collection_propstat(
|
||||
cal, f"/caldav/{cal.id}/",
|
||||
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
|
||||
return _multistatus("\n".join(parts))
|
||||
|
||||
|
||||
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
user = _basic_auth_user(request, db)
|
||||
if not user:
|
||||
return _unauthorized()
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == cal_id,
|
||||
models.LocalCalendar.user_id == user.id,
|
||||
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
return Response(status_code=404)
|
||||
base = f"/caldav/{cal.id}/"
|
||||
return await _dispatch_collection(request, cal, base, resource, db,
|
||||
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
|
||||
|
||||
|
||||
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
|
||||
async def wellknown_caldav(request: Request):
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return _handle_options()
|
||||
# Point discovery at the principal/home collection.
|
||||
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
|
||||
|
||||
|
||||
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
|
||||
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
|
||||
async def caldav_home(request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_home(request, db)
|
||||
|
||||
|
||||
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
|
||||
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_auth_calendar(request, cal_id, "", db)
|
||||
|
||||
|
||||
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
|
||||
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
|
||||
return await _dispatch_auth_calendar(request, cal_id, resource, db)
|
||||
@@ -382,15 +382,35 @@ def update_calendar(
|
||||
|
||||
# ── Events ───────────────────────────────────────────────
|
||||
|
||||
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
||||
"""Fetch events from all enabled Google calendars for an account."""
|
||||
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
|
||||
"""Fetch events from all enabled Google calendars for an account.
|
||||
|
||||
Returns (events, errors) — errors is a list of
|
||||
{"source": "google", "name": ..., "message": ...} dicts for any
|
||||
calendar that failed to sync. Never includes raw exception text.
|
||||
"""
|
||||
all_events = []
|
||||
errors = []
|
||||
try:
|
||||
token = _refresh_access_token(account, db)
|
||||
except Exception as exc:
|
||||
# A token failure aborts the whole account, but attribute it to each
|
||||
# enabled calendar so clients can pin the error to a specific calendar
|
||||
# (and preserve that calendar's cached events instead of wiping it).
|
||||
logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
|
||||
raise
|
||||
for gcal in account.calendars:
|
||||
if not gcal.enabled or gcal.sidebar_hidden:
|
||||
continue
|
||||
if _is_system_calendar(gcal.cal_id):
|
||||
continue
|
||||
errors.append({
|
||||
"source": "google",
|
||||
"name": f"{account.email} – {gcal.name}",
|
||||
"calendar_id": gcal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
return all_events, errors
|
||||
|
||||
all_events = []
|
||||
for gcal in account.calendars:
|
||||
if not gcal.enabled or gcal.sidebar_hidden:
|
||||
continue
|
||||
@@ -409,8 +429,14 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
|
||||
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
|
||||
errors.append({
|
||||
"source": "google",
|
||||
"name": f"{account.email} – {gcal.name}",
|
||||
"calendar_id": gcal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
|
||||
return all_events
|
||||
return all_events, errors
|
||||
|
||||
|
||||
class GoogleEventCreate(BaseModel):
|
||||
|
||||
@@ -176,11 +176,22 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
|
||||
member_dicts = []
|
||||
for i, m in enumerate(members):
|
||||
u = db.query(models.User).filter(models.User.id == m.user_id).first()
|
||||
# Whether this member actually shares a calendar into the group (owns a
|
||||
# calendar designated as their group_visible). Lets clients hide phantom
|
||||
# empty rows for members who share nothing.
|
||||
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first()
|
||||
shares_calendar = False
|
||||
if s and s.group_visible_calendar_id is not None:
|
||||
shares_calendar = db.query(models.LocalCalendar.id).filter(
|
||||
models.LocalCalendar.id == s.group_visible_calendar_id,
|
||||
models.LocalCalendar.user_id == m.user_id,
|
||||
).first() is not None
|
||||
member_dicts.append({
|
||||
"id": m.user_id,
|
||||
"display_name": (u.display_name or u.username) if u else None,
|
||||
"role": m.role,
|
||||
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
|
||||
"shares_calendar": shares_calendar,
|
||||
})
|
||||
gcal_id = _group_calendar_id(db, group.id)
|
||||
return {
|
||||
@@ -317,17 +328,12 @@ def _first_name(name: Optional[str]) -> str:
|
||||
|
||||
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
|
||||
owner: Optional[dict], me_id: int) -> str:
|
||||
"""Server-side display title for the combined view so every client (web,
|
||||
iOS, Android) renders identically: another member's / creator's first name
|
||||
is prefixed. No icon glyph is embedded — group icons are semantic keys the
|
||||
clients render as native vector icons, and group-calendar events are
|
||||
distinguished by their (group) colour. The raw `title` stays for editing."""
|
||||
if is_group:
|
||||
if creator and creator.get("id") is not None and creator.get("id") != me_id:
|
||||
return f"{_first_name(creator.get('display_name'))}: {title}"
|
||||
return title
|
||||
if owner and owner.get("id") is not None and owner.get("id") != me_id:
|
||||
return f"{_first_name(owner.get('display_name'))}: {title}"
|
||||
"""Server-side display title for the combined view. The former owner/creator
|
||||
first-name prefix ("Guido: …") was dropped: each member already has a
|
||||
distinct colour, so the prefix was redundant noise. We still return a
|
||||
non-empty `display_title` (== raw title) so the clients' legacy fallback —
|
||||
which rebuilds a prefix when `display_title` is empty — never kicks in.
|
||||
`display_color` continues to carry the per-person colour."""
|
||||
return title
|
||||
|
||||
|
||||
|
||||
@@ -313,13 +313,32 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) ->
|
||||
}
|
||||
|
||||
|
||||
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
||||
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
|
||||
"""Fetch events from all enabled HA calendars for an account.
|
||||
|
||||
Returns (events, errors) — errors is a list of
|
||||
{"source": "homeassistant", "name": ..., "calendar_id": ..., "message": ...}
|
||||
dicts for any calendar that failed to sync. Never includes raw exception text.
|
||||
"""
|
||||
all_events = []
|
||||
errors = []
|
||||
try:
|
||||
token = _get_valid_token(account, db)
|
||||
except Exception as exc:
|
||||
# A token failure aborts the whole account, but attribute it to each
|
||||
# enabled calendar so clients can pin the error to a specific calendar
|
||||
# (and preserve that calendar's cached events instead of wiping it).
|
||||
logger.error("HA token error for %s: %s", account.name, exc)
|
||||
raise
|
||||
for cal in account.calendars:
|
||||
if not cal.enabled or cal.sidebar_hidden:
|
||||
continue
|
||||
errors.append({
|
||||
"source": "homeassistant",
|
||||
"name": f"{account.name} – {cal.name}",
|
||||
"calendar_id": cal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
return all_events, errors
|
||||
for cal in account.calendars:
|
||||
if not cal.enabled or cal.sidebar_hidden:
|
||||
continue
|
||||
@@ -330,7 +349,13 @@ def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_
|
||||
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
|
||||
except Exception as exc:
|
||||
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
|
||||
return all_events
|
||||
errors.append({
|
||||
"source": "homeassistant",
|
||||
"name": f"{account.name} – {cal.name}",
|
||||
"calendar_id": cal.id,
|
||||
"message": "Sync fehlgeschlagen",
|
||||
})
|
||||
return all_events, errors
|
||||
|
||||
|
||||
# ── Serialization ─────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests as http_requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -45,13 +48,67 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _fetch_ics(url: str) -> str:
|
||||
"""Download .ics content from a URL."""
|
||||
_MAX_ICS_BYTES = 5 * 1024 * 1024
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def _host_is_public(host: str) -> bool:
|
||||
"""False if the host resolves to any private/loopback/link-local address."""
|
||||
try:
|
||||
resp = http_requests.get(url, timeout=30, allow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
resp.encoding = 'utf-8'
|
||||
return resp.text
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
except ValueError:
|
||||
return False
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_public_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Nur http(s)-URLs sind erlaubt.")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("Ungültige URL.")
|
||||
if not _host_is_public(parsed.hostname):
|
||||
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
|
||||
|
||||
|
||||
def _fetch_ics(url: str) -> str:
|
||||
"""Download .ics content, blocking SSRF to internal/private hosts.
|
||||
|
||||
Redirects are followed manually so every hop is re-validated (a public URL
|
||||
can otherwise 30x-redirect into the internal network). Response size is
|
||||
capped. (Residual risk: DNS rebinding between check and connect.)
|
||||
"""
|
||||
if url.startswith("webcal://"):
|
||||
url = "https://" + url[len("webcal://"):]
|
||||
try:
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
_validate_public_url(url)
|
||||
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True)
|
||||
if resp.is_redirect and resp.headers.get("location"):
|
||||
url = urljoin(url, resp.headers["location"])
|
||||
resp.close()
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
resp.encoding = "utf-8"
|
||||
chunks, total = [], 0
|
||||
for chunk in resp.iter_content(8192, decode_unicode=True):
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > _MAX_ICS_BYTES:
|
||||
resp.close()
|
||||
raise ValueError("Datei zu groß (max. 5 MB).")
|
||||
return "".join(chunks)
|
||||
raise ValueError("Zu viele Weiterleitungen.")
|
||||
except http_requests.RequestException as e:
|
||||
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import dav_util
|
||||
import ical_io
|
||||
import models
|
||||
import permissions
|
||||
@@ -31,6 +32,7 @@ class CalendarUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
caldav_published: Optional[bool] = None
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -67,16 +69,24 @@ class ShareCreate(BaseModel):
|
||||
|
||||
|
||||
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||
shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
|
||||
shared_by: Optional[str] = None, permission: Optional[str] = None,
|
||||
color_override: Optional[str] = None,
|
||||
request: Optional[Request] = None) -> dict:
|
||||
d = {
|
||||
"id": cal.id,
|
||||
"name": cal.name,
|
||||
"color": cal.color,
|
||||
# A recipient's own colour for a shared calendar wins over the owner's.
|
||||
"color": color_override or cal.color,
|
||||
"enabled": cal.enabled,
|
||||
"reminders_enabled": bool(cal.reminders_enabled),
|
||||
"caldav_published": bool(cal.caldav_published),
|
||||
"type": "local",
|
||||
"owned": owned,
|
||||
}
|
||||
# Only the owner may publish; expose the subscribe URLs only when active.
|
||||
if owned and cal.caldav_published and cal.dav_token:
|
||||
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
|
||||
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
|
||||
if shared_by is not None:
|
||||
d["shared_by"] = shared_by
|
||||
if permission is not None:
|
||||
@@ -92,6 +102,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -
|
||||
|
||||
@router.get("/calendars")
|
||||
def list_calendars(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
@@ -117,7 +128,7 @@ def list_calendars(
|
||||
)
|
||||
result = []
|
||||
for c in own:
|
||||
d = _cal_dict(c, owned=True)
|
||||
d = _cal_dict(c, owned=True, request=request)
|
||||
if c.id in group_cal_map:
|
||||
d["group"] = True
|
||||
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
||||
@@ -141,6 +152,7 @@ def list_calendars(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission=share.permission,
|
||||
color_override=share.color,
|
||||
)
|
||||
if cal.id in group_cal_map:
|
||||
d["group"] = True
|
||||
@@ -158,6 +170,23 @@ def list_calendars(
|
||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
||||
d["group"] = True
|
||||
result.append(d)
|
||||
|
||||
# Calendars co-members share into shared groups (group_visible_calendar_id).
|
||||
# Read-only, shown under the owner's name. Deduped against everything above
|
||||
# so a calendar already shared directly / as a group calendar isn't doubled.
|
||||
for cal in permissions.co_member_group_visible_calendars(db, current_user):
|
||||
if cal.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cal.id)
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
d = _cal_dict(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission="read",
|
||||
request=request,
|
||||
)
|
||||
d["group_shared"] = True
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
@@ -182,6 +211,7 @@ def create_calendar(
|
||||
def update_calendar(
|
||||
calendar_id: int,
|
||||
data: CalendarUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
@@ -203,8 +233,74 @@ def update_calendar(
|
||||
cal.enabled = data.enabled
|
||||
if data.reminders_enabled is not None:
|
||||
cal.reminders_enabled = data.reminders_enabled
|
||||
if data.caldav_published is not None:
|
||||
cal.caldav_published = data.caldav_published
|
||||
if data.caldav_published:
|
||||
# First publish: mint a token + initial ctag so clients can sync.
|
||||
if not cal.dav_token:
|
||||
cal.dav_token = dav_util.new_token()
|
||||
if not cal.dav_ctag:
|
||||
cal.dav_ctag = dav_util.new_tag()
|
||||
else:
|
||||
# Unpublishing revokes access: drop the token so the URL 404s.
|
||||
cal.dav_token = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
db.refresh(cal)
|
||||
return _cal_dict(cal, owned=True, request=request)
|
||||
|
||||
|
||||
class CalendarColorUpdate(BaseModel):
|
||||
color: str
|
||||
|
||||
|
||||
@router.put("/calendars/{calendar_id}/color")
|
||||
def set_calendar_color(
|
||||
calendar_id: int,
|
||||
data: CalendarColorUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Set a calendar's colour. The owner changes the calendar's colour for
|
||||
everyone; a share recipient sets only their OWN per-user colour (stored on
|
||||
the share) without touching the owner's. Recipients may recolour but never
|
||||
rename a shared calendar."""
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
|
||||
if cal.user_id == current_user.id:
|
||||
cal.color = data.color
|
||||
else:
|
||||
share = permissions._share_for(db, calendar_id, current_user.id)
|
||||
if share is None:
|
||||
raise HTTPException(403, "Only the owner or a share recipient can set the colour")
|
||||
share.color = data.color
|
||||
db.commit()
|
||||
return {"ok": True, "color": data.color}
|
||||
|
||||
|
||||
@router.post("/calendars/{calendar_id}/dav-token/rotate")
|
||||
def rotate_dav_token(
|
||||
calendar_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Issue a fresh CalDAV token — the old subscribe URL stops working."""
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == calendar_id,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if not cal.caldav_published:
|
||||
raise HTTPException(422, "Calendar is not published")
|
||||
cal.dav_token = dav_util.new_token()
|
||||
cal.dav_ctag = dav_util.new_tag()
|
||||
db.commit()
|
||||
db.refresh(cal)
|
||||
return _cal_dict(cal, owned=True, request=request)
|
||||
|
||||
|
||||
@router.delete("/calendars/{calendar_id}")
|
||||
@@ -257,6 +353,7 @@ def create_event(
|
||||
creator_id=current_user.id, # server-side, never from the client
|
||||
)
|
||||
db.add(ev)
|
||||
dav_util.bump_dav(cal, ev)
|
||||
db.commit()
|
||||
db.refresh(ev)
|
||||
return _event_dict(ev, cal, db)
|
||||
@@ -305,6 +402,7 @@ def update_event(
|
||||
ev.exdate = ",".join(dates)
|
||||
if data.reminders is not None:
|
||||
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||
dav_util.bump_dav(ev.calendar, ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -316,6 +414,7 @@ def delete_event(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ev = _writable_event(db, current_user, uid)
|
||||
dav_util.bump_dav(ev.calendar)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -441,9 +540,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
|
||||
rrule=item.get("rrule"),
|
||||
exdate=item.get("exdate"),
|
||||
creator_name_external=item.get("organizer"),
|
||||
etag=dav_util.new_tag(),
|
||||
)
|
||||
db.add(ev)
|
||||
imported += 1
|
||||
if imported:
|
||||
dav_util.bump_dav(cal)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import io
|
||||
import re
|
||||
import base64
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -32,6 +34,7 @@ class ProfileUpdate(BaseModel):
|
||||
email: Optional[str] = Field(default=None, max_length=120)
|
||||
display_name: Optional[str] = Field(default=None, max_length=80)
|
||||
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
|
||||
directory_hidden: Optional[bool] = None # hide from sharing/group pickers
|
||||
|
||||
|
||||
def _strip_controls(s: str) -> str:
|
||||
@@ -64,6 +67,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
|
||||
"is_admin": current_user.is_admin,
|
||||
"has_avatar": current_user.avatar_filename is not None,
|
||||
"totp_enabled": current_user.totp_enabled,
|
||||
"directory_hidden": bool(current_user.directory_hidden),
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +111,15 @@ def update_profile(
|
||||
if taken:
|
||||
raise HTTPException(400, "Username already taken")
|
||||
current_user.username = new_login
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
db.commit()
|
||||
# The JWT 'sub' is the login name — renaming it invalidates the old
|
||||
# token, so hand back a fresh one for the client to store.
|
||||
result["access_token"] = create_access_token({"sub": new_login})
|
||||
return result
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
@@ -168,7 +176,11 @@ def get_avatar(current_user: models.User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.get("/avatar/{user_id}")
|
||||
def get_user_avatar(user_id: int, db: Session = Depends(get_db)):
|
||||
def get_user_avatar(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user or not user.avatar_filename:
|
||||
raise HTTPException(404, "Kein Profilbild")
|
||||
@@ -264,3 +276,74 @@ def disable_totp(
|
||||
current_user.totp_enabled = False
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── App passwords (for CalDAV Basic Auth) ────────────────
|
||||
class AppPasswordCreate(BaseModel):
|
||||
label: str = Field(default="CalDAV", max_length=100)
|
||||
|
||||
|
||||
def _app_pw_dict(ap: models.AppPassword) -> dict:
|
||||
return {
|
||||
"id": ap.id,
|
||||
"label": ap.label,
|
||||
"created_at": ap.created_at,
|
||||
"last_used_at": ap.last_used_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/app-passwords")
|
||||
def list_app_passwords(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
rows = (
|
||||
db.query(models.AppPassword)
|
||||
.filter(models.AppPassword.user_id == current_user.id)
|
||||
.order_by(models.AppPassword.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [_app_pw_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/app-passwords")
|
||||
def create_app_password(
|
||||
data: AppPasswordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Show the plaintext exactly once; only the hash is stored.
|
||||
password = secrets.token_urlsafe(18)
|
||||
ap = models.AppPassword(
|
||||
user_id=current_user.id,
|
||||
label=(data.label or "CalDAV")[:100],
|
||||
password_hash=get_password_hash(password),
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
db.add(ap)
|
||||
db.commit()
|
||||
db.refresh(ap)
|
||||
out = _app_pw_dict(ap)
|
||||
out["password"] = password
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/app-passwords/{ap_id}")
|
||||
def delete_app_password(
|
||||
ap_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ap = (
|
||||
db.query(models.AppPassword)
|
||||
.filter(
|
||||
models.AppPassword.id == ap_id,
|
||||
models.AppPassword.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ap:
|
||||
raise HTTPException(404, "App password not found")
|
||||
db.delete(ap)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -53,7 +53,10 @@ def user_directory(
|
||||
"""
|
||||
users = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.id != current_user.id)
|
||||
.filter(
|
||||
models.User.id != current_user.id,
|
||||
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
|
||||
)
|
||||
.order_by(models.User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -409,3 +409,78 @@ def test_import_export_only_local(client):
|
||||
cal_id = _make_calendar(client, admin, "Privat")
|
||||
# Bob has no access -> 404 on export.
|
||||
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
|
||||
|
||||
|
||||
# ── Group-visible calendars propagate to co-members' sidebars ─────────────
|
||||
|
||||
def test_group_visible_propagates_to_member_sidebar(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
|
||||
# Bob designates a calendar as group-visible; admin (co-member) should see it.
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
shared = [c for c in cals if c["id"] == b_cal]
|
||||
assert len(shared) == 1, shared
|
||||
assert shared[0]["owned"] is False
|
||||
assert shared[0]["shared_by"] == "bob"
|
||||
assert shared[0]["permission"] == "read"
|
||||
assert shared[0].get("group_shared") is True
|
||||
|
||||
# Its events appear in the normal merged read, read-only.
|
||||
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||
bob_ev = [e for e in events if e["title"] == "Bobs Termin"]
|
||||
assert bob_ev and bob_ev[0].get("read_only") is True
|
||||
|
||||
|
||||
def test_group_visible_absent_when_not_designated(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
assert not any(c["id"] == b_cal for c in cals)
|
||||
|
||||
|
||||
def test_group_visible_not_duplicated_with_direct_share(client):
|
||||
admin = register_admin(client)
|
||||
admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"]
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
# Also directly shared with admin (read_write) — must not double-list.
|
||||
client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok),
|
||||
json={"user_id": admin_id, "permission": "read_write"})
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
matches = [c for c in cals if c["id"] == b_cal]
|
||||
assert len(matches) == 1, matches
|
||||
# The direct share wins (read_write, not flagged group_shared).
|
||||
assert matches[0]["permission"] == "read_write"
|
||||
assert matches[0].get("group_shared") is not True
|
||||
|
||||
|
||||
# ── Hidden profile (directory opt-out) ────────────────────────────────────
|
||||
|
||||
def test_directory_hidden_excludes_from_picker_but_not_admin(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/directory", headers=auth(admin)).json())
|
||||
|
||||
r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
assert not any(u["id"] == b_id for u in
|
||||
client.get("/api/users/directory", headers=auth(admin)).json())
|
||||
# Admin user management still lists the hidden user.
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/", headers=auth(admin)).json())
|
||||
|
||||
@@ -1462,6 +1462,22 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; }
|
||||
.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||
.ct-toggle { cursor: pointer; }
|
||||
.ct-dav-toggle.on { color: var(--primary); }
|
||||
.ct-dav-row td { padding-top: 4px; padding-bottom: 12px; }
|
||||
.ct-dav-box { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ct-dav-label { font-size: 12px; color: var(--text-3); }
|
||||
.ct-dav-url {
|
||||
flex: 1; min-width: 220px; font-size: 12px; padding: 5px 8px;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface-2); color: var(--text-1);
|
||||
}
|
||||
.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; }
|
||||
.app-pw-create { display: flex; gap: 8px; align-items: center; }
|
||||
.app-pw-create input { flex: 1; }
|
||||
.app-pw-new { margin: 8px 0; }
|
||||
.app-pw-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid var(--border); }
|
||||
.app-pw-name { font-weight: 500; }
|
||||
.app-pw-meta { font-size: 11px; color: var(--text-3); margin-left: auto; }
|
||||
.ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; }
|
||||
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
|
||||
.ct-eye:hover, .ct-bell:hover { opacity: 1; }
|
||||
@@ -1986,12 +2002,21 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-radius: 10px;
|
||||
}
|
||||
.share-user-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.share-user-item:last-child { border-bottom: none; }
|
||||
.share-user-item:hover { background: var(--bg-surface); }
|
||||
.share-user-item input[type=checkbox] { flex-shrink: 0; }
|
||||
/* "Shared with me" section header row inside the calendar-management table */
|
||||
.ct-section-row td {
|
||||
padding-top: 14px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-3); text-transform: uppercase; letter-spacing: .04em;
|
||||
}
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.checkbox-row input[type=checkbox] { flex-shrink: 0; }
|
||||
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
|
||||
|
||||
/* ── Groups ─────────────────────────────────────────────────── */
|
||||
@@ -2123,6 +2148,8 @@ a { color: var(--primary); text-decoration: none; }
|
||||
/* Group emoji + icon picker */
|
||||
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
|
||||
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
|
||||
/* Read-only (shared with me) indicator: a struck-through pencil. */
|
||||
.cal-readonly-flag { color: var(--text-3); opacity: .7; margin-left: 2px; }
|
||||
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.group-icon-opt {
|
||||
width: 38px; height: 38px;
|
||||
|
||||
@@ -716,6 +716,23 @@
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
|
||||
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p>
|
||||
<div class="form-group app-pw-create">
|
||||
<input type="text" id="app-pw-label" data-i18n-placeholder="app_pw_label_ph" placeholder="Name (z.B. iPhone)" maxlength="100" />
|
||||
<button class="btn btn-primary btn-sm" id="app-pw-create-btn" data-i18n="app_pw_create">Erstellen</button>
|
||||
</div>
|
||||
<div id="app-pw-new" class="app-pw-new hidden">
|
||||
<label data-i18n="app_pw_new_label">Neues App-Passwort (nur jetzt sichtbar):</label>
|
||||
<div class="totp-secret-row">
|
||||
<code id="app-pw-new-value"></code>
|
||||
<button class="btn btn-ghost btn-sm" id="app-pw-copy" title="Kopieren">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app-pw-list"></div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
|
||||
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
|
||||
<div class="form-group">
|
||||
@@ -725,6 +742,13 @@
|
||||
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="cfg-directory-hidden" />
|
||||
<span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span>
|
||||
</label>
|
||||
<p class="panel-desc" data-i18n="settings_directory_hidden_desc">Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.</p>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
|
||||
<div class="contrast-selector" id="cfg-event-duration">
|
||||
|
||||
@@ -23,14 +23,23 @@ async function boot() {
|
||||
// Check if already logged in
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
let authed = false;
|
||||
try {
|
||||
await api.get('/auth/me'); // validate token
|
||||
await launchApp();
|
||||
return;
|
||||
await api.get('/auth/me'); // validate the TOKEN only
|
||||
authed = true;
|
||||
} catch (_) {
|
||||
// The token is genuinely invalid/expired — clear it and show login.
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
if (authed) {
|
||||
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
|
||||
// try/catch on purpose: a later data/render error inside app init must
|
||||
// never bounce a validly-authenticated user back to the login screen
|
||||
// (that was the "logged out on every reload" bug).
|
||||
await launchApp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showScreen('login');
|
||||
@@ -192,8 +201,23 @@ boot();
|
||||
|
||||
// ── Service Worker registration (PWA) ─────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Auto-update: when a new service worker takes control, reload once so the
|
||||
// page runs the fresh assets. Guarded so it never loops and never fires on
|
||||
// the very first install (when there was no previous controller).
|
||||
let refreshing = false;
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing || !hadController) return;
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => {
|
||||
// Check for a new SW now and hourly, so long-open tabs pick up releases.
|
||||
reg.update();
|
||||
setInterval(() => reg.update(), 60 * 60 * 1000);
|
||||
}).catch(err => {
|
||||
console.warn('SW registration failed:', err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,6 +305,7 @@ function setGroupColorOverride(groupId, key, hex) {
|
||||
try { localStorage.setItem('groupMemberColors', JSON.stringify(all)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
async function fetchAndRender(force = false, silent = false) {
|
||||
const { start, end } = getViewRange();
|
||||
|
||||
@@ -377,8 +378,10 @@ async function fetchAndRender(force = false, silent = false) {
|
||||
const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
|
||||
const events = resp.events || resp;
|
||||
if (resp.errors && resp.errors.length) {
|
||||
const sourceLabels = { caldav: 'CalDAV', homeassistant: 'Home Assistant', google: 'Google' };
|
||||
for (const err of resp.errors) {
|
||||
showToast(`Google (${err.email}): Token abgelaufen – bitte Konto trennen und neu verbinden`, true);
|
||||
const label = sourceLabels[err.source] || err.source || 'Kalender';
|
||||
showToast(`${label} (${err.name}): ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
eventCache.start = fetchStart;
|
||||
@@ -726,9 +729,13 @@ function renderCalendarList() {
|
||||
remove: { icon: TRASH, title: t('remove_cal') } });
|
||||
});
|
||||
state.localCalendars.filter(c => c.owned === false && !c.group).forEach(cal => {
|
||||
const readOnly = cal.permission !== 'read_write';
|
||||
// A shared calendar is shown to the recipient under the OWNER's name
|
||||
// (e.g. Guido's "Persönlich" appears as "Guido"); the original calendar
|
||||
// name stays in the sub-label so it's still identifiable.
|
||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color, enabled: cal.enabled,
|
||||
sourceLabel: `${t('shared_with_me')} · ${cal.shared_by || ''}`, remove: null });
|
||||
name: cal.shared_by || cal.name, color: cal.color, enabled: cal.enabled, readOnly,
|
||||
sourceLabel: `${t('shared_with_me')} · ${cal.name}${readOnly ? ' · ' + t('perm_read') : ''}`, remove: null });
|
||||
});
|
||||
// Group calendars (owned by the creator or reached via membership) — shown so
|
||||
// they can be toggled/recoloured. Owned group calendars can also be muted
|
||||
@@ -787,6 +794,7 @@ function renderCalendarList() {
|
||||
${e.isGroupCal ? `<span class="cal-shared-flag" title="${escHtml(e.sourceLabel)}">${groupIconSvg(e.groupIcon || 'people', 13)}</span>` : ''}
|
||||
${e.groupVisible ? `<span class="cal-shared-flag cal-shared-flag-own" title="${t('group_visible_flag')}">${shareIconSvg(state.settings?.share_calendar_icon || 'share', 13)}</span>` : ''}
|
||||
<span class="cal-item-name" data-source="${e.source}">${escHtml(e.name)}</span>
|
||||
${e.readOnly ? `<span class="cal-shared-flag cal-readonly-flag" title="${t('perm_read')}"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/><line x1="3" y1="3" x2="21" y2="21"/></svg></span>` : ''}
|
||||
${e.reminders ? `<button class="icon-btn mini-btn cal-item-bell ${e.remindersEnabled ? '' : 'off'}" data-source="${e.source}" ${e.dataId} title="${e.remindersEnabled ? t('calendar_reminders_on') : t('calendar_reminders_off')}">${e.remindersEnabled ? BELL : BELL_OFF}</button>` : ''}
|
||||
${e.remove ? `<button class="icon-btn mini-btn cal-item-remove" data-source="${e.source}" ${e.dataId} title="${e.remove.title}">${e.remove.icon}</button>` : ''}
|
||||
</div>`
|
||||
@@ -898,10 +906,12 @@ function renderCalendarList() {
|
||||
} else if (source === 'local') {
|
||||
const calId = parseInt(dot.dataset.calId);
|
||||
const cal = state.localCalendars.find(c => c.id === calId);
|
||||
if (cal && cal.owned === false) { showToast(t('only_owner_color'), true); return; }
|
||||
const picked = await openColorPicker(dot, cal?.color || '#34a853');
|
||||
if (picked) {
|
||||
await api.put(`/local/calendars/${calId}`, { color: picked });
|
||||
// Colour-only endpoint: the owner sets the calendar's colour, a share
|
||||
// recipient sets their OWN per-user colour (server-side, synced across
|
||||
// devices) — without write access or the ability to rename.
|
||||
await api.put(`/local/calendars/${calId}/color`, { color: picked });
|
||||
if (cal) cal.color = picked;
|
||||
applyCalendarColor('local', calId, picked);
|
||||
}
|
||||
@@ -950,6 +960,12 @@ function renderCalendarList() {
|
||||
e.stopPropagation();
|
||||
const item = nameEl.closest('.cal-item');
|
||||
const source = nameEl.dataset.source;
|
||||
// Can't rename a calendar shared with me — only the owner may. Renaming
|
||||
// would just 403 on save, so don't even start editing.
|
||||
if (source === 'local') {
|
||||
const cal = state.localCalendars.find(c => c.id === parseInt(item?.dataset.calId));
|
||||
if (cal && cal.owned === false) return;
|
||||
}
|
||||
const currentName = nameEl.textContent;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
@@ -1575,8 +1591,10 @@ function populateCalendarSelect(selectedId) {
|
||||
});
|
||||
});
|
||||
// Local calendars. Group calendars are collected under a "Groups" optgroup
|
||||
// (no emoji — a native <select> can't render the group icon SVG).
|
||||
const localCals = state.localCalendars.filter(c => !c.sidebar_hidden);
|
||||
// (no emoji — a native <select> can't render the group icon SVG). Read-only
|
||||
// shared calendars are excluded — a save would only 403.
|
||||
const localCals = state.localCalendars.filter(c =>
|
||||
!c.sidebar_hidden && (c.owned !== false || c.permission === 'read_write'));
|
||||
const makeLocalOpt = cal => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `local-${cal.id}`;
|
||||
@@ -2674,31 +2692,42 @@ async function refreshShareModal(calendarId) {
|
||||
});
|
||||
});
|
||||
|
||||
// Store the directory (minus already-shared users) for the picker.
|
||||
document.getElementById('modal-share').__users = users.filter(u => !sharedIds.has(u.id));
|
||||
// Store the full directory + which users are already shared, so the picker
|
||||
// can show a checkbox per user (checked = shared).
|
||||
const modal = document.getElementById('modal-share');
|
||||
modal.__users = users;
|
||||
modal.__sharedIds = sharedIds;
|
||||
renderShareUserPicker();
|
||||
}
|
||||
|
||||
function renderShareUserPicker() {
|
||||
const modal = document.getElementById('modal-share');
|
||||
const users = modal.__users || [];
|
||||
const sharedIds = modal.__sharedIds || new Set();
|
||||
const q = (document.getElementById('share-user-search').value || '').toLowerCase();
|
||||
const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q));
|
||||
const picker = document.getElementById('share-user-picker');
|
||||
picker.innerHTML = filtered.length
|
||||
? filtered.map(u =>
|
||||
`<div class="share-user-item" data-user-id="${u.id}">${escHtml(u.display_name || '')}</div>`
|
||||
`<label class="share-user-item">
|
||||
<input type="checkbox" class="share-user-cb" data-user-id="${u.id}" ${sharedIds.has(u.id) ? 'checked' : ''} />
|
||||
<span>${escHtml(u.display_name || '')}</span>
|
||||
</label>`
|
||||
).join('')
|
||||
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
|
||||
picker.querySelectorAll('.share-user-item').forEach(el => {
|
||||
el.addEventListener('click', async () => {
|
||||
picker.querySelectorAll('.share-user-cb').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const calId = parseInt(modal.dataset.calId);
|
||||
const permission = document.getElementById('share-permission').value;
|
||||
const userId = parseInt(cb.dataset.userId);
|
||||
try {
|
||||
await api.post(`/local/calendars/${calId}/shares`,
|
||||
{ user_id: parseInt(el.dataset.userId), permission });
|
||||
if (cb.checked) {
|
||||
const permission = document.getElementById('share-permission').value;
|
||||
await api.post(`/local/calendars/${calId}/shares`, { user_id: userId, permission });
|
||||
} else {
|
||||
await api.delete(`/local/calendars/${calId}/shares/${userId}`);
|
||||
}
|
||||
await refreshShareModal(calId);
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
} catch (e) { showToast(e.message, true); cb.checked = !cb.checked; }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2794,7 +2823,11 @@ function renderGroupMemberFilter() {
|
||||
<span class="cal-item-name">${escHtml(name)}</span>
|
||||
</div>`;
|
||||
};
|
||||
const parts = (g.members || []).map(m => row(m.id, String(m.id), m.display_name || '—', m.color));
|
||||
// Only members who actually share a calendar into the group get a toggle —
|
||||
// otherwise we'd show phantom empty entries for members who share nothing.
|
||||
const parts = (g.members || [])
|
||||
.filter(m => m.shares_calendar)
|
||||
.map(m => row(m.id, String(m.id), m.display_name || '—', m.color));
|
||||
parts.push(row('gc', 'gc', t('group_calendar'), g.group_calendar_color));
|
||||
items.innerHTML = parts.join('');
|
||||
|
||||
@@ -3109,7 +3142,10 @@ function openSettingsModal() {
|
||||
document.getElementById('cfg-login-name').value = pu.username || '';
|
||||
api.get('/profile/').then(p => {
|
||||
document.getElementById('cfg-email').value = p.email || '';
|
||||
const dh = document.getElementById('cfg-directory-hidden');
|
||||
if (dh) dh.checked = !!p.directory_hidden;
|
||||
}).catch(() => {});
|
||||
initAppPasswords();
|
||||
|
||||
// Set active contrast/hour-height/duration buttons
|
||||
[
|
||||
@@ -3442,24 +3478,43 @@ function renderCalendarTable() {
|
||||
|
||||
let rows = '';
|
||||
|
||||
// Local calendars
|
||||
// Local calendars I own. Calendars shared WITH me (owned === false) are shown
|
||||
// in a separate "shared with me" section further down.
|
||||
for (const cal of state.localCalendars) {
|
||||
if (cal.group) continue;
|
||||
const owned = cal.owned !== false;
|
||||
const canWrite = owned || cal.permission === 'read_write';
|
||||
if (cal.group || cal.owned === false) continue;
|
||||
const pub = !!cal.caldav_published;
|
||||
rows += `<tr>
|
||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
||||
<td class="ct-src">Lokal</td>
|
||||
<td>—</td>
|
||||
<td>${owned ? rem('local', cal.id, cal.reminders_enabled !== false) : '—'}</td>
|
||||
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
||||
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
||||
${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
|
||||
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
|
||||
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
|
||||
</tr>`;
|
||||
rowCount++;
|
||||
if (owned && pub) {
|
||||
rows += `<tr class="ct-dav-row"><td colspan="7">
|
||||
<div class="ct-dav-box">
|
||||
<span class="ct-dav-label">${t('caldav_published_url')}</span>
|
||||
<input class="ct-dav-url" type="text" readonly value="${escHtml(cal.caldav_url || '')}">
|
||||
<button class="btn btn-ghost btn-sm ct-dav-copy">${t('copy')}</button>
|
||||
<button class="btn btn-ghost btn-sm ct-dav-rotate" data-ct-dav-id="${cal.id}">${t('caldav_token_rotate')}</button>
|
||||
</div>
|
||||
<div class="ct-dav-hint">${t('caldav_hint')}</div>
|
||||
<div class="ct-dav-box">
|
||||
<span class="ct-dav-label">${t('caldav_login_url')}</span>
|
||||
<input class="ct-dav-url" type="text" readonly value="${escHtml(cal.caldav_login_url || '')}">
|
||||
<button class="btn btn-ghost btn-sm ct-dav-copy">${t('copy')}</button>
|
||||
</div>
|
||||
<div class="ct-dav-hint">${t('caldav_login_hint')}</div>
|
||||
</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// iCal subscriptions
|
||||
@@ -3539,6 +3594,22 @@ function renderCalendarTable() {
|
||||
}
|
||||
}
|
||||
|
||||
// Calendars shared WITH me (direct shares + group-shared) — read-only, no
|
||||
// management actions since I don't own them. Shown under the owner's name.
|
||||
const sharedWithMe = state.localCalendars.filter(c => c.owned === false && !c.group);
|
||||
if (sharedWithMe.length) {
|
||||
rows += `<tr class="ct-section-row"><td colspan="7">${t('shared_with_me')}</td></tr>`;
|
||||
for (const cal of sharedWithMe) {
|
||||
const readOnly = cal.permission !== 'read_write';
|
||||
rows += `<tr>
|
||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}</td>
|
||||
<td class="ct-src">${escHtml(cal.name)} <span class="cal-badge">${readOnly ? t('perm_read') : t('perm_read_write')}</span></td>
|
||||
<td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
|
||||
</tr>`;
|
||||
rowCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowCount) {
|
||||
rows = `<tr><td colspan="7" class="ct-empty">Keine Kalender vorhanden</td></tr>`;
|
||||
}
|
||||
@@ -3656,6 +3727,47 @@ function renderCalendarTable() {
|
||||
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId)));
|
||||
});
|
||||
|
||||
// Share a calendar with specific users (person-to-person)
|
||||
container.querySelectorAll('[data-ct-share]').forEach(btn => {
|
||||
btn.addEventListener('click', () => openShareModal(parseInt(btn.dataset.ctShare)));
|
||||
});
|
||||
|
||||
// CalDAV publishing
|
||||
container.querySelectorAll('.ct-dav-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = parseInt(btn.dataset.ctDavId);
|
||||
const newVal = btn.dataset.ctDavOn !== '1';
|
||||
const cal = state.localCalendars.find(c => c.id === id);
|
||||
if (!cal) return;
|
||||
try {
|
||||
const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal });
|
||||
cal.caldav_published = updated.caldav_published;
|
||||
cal.caldav_url = updated.caldav_url;
|
||||
renderCalendarTable();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.ct-dav-copy').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = btn.parentElement.querySelector('.ct-dav-url');
|
||||
if (!input || !input.value) return;
|
||||
navigator.clipboard.writeText(input.value).then(() => showToast(t('caldav_url_copied')));
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.ct-dav-rotate').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = parseInt(btn.dataset.ctDavId);
|
||||
const cal = state.localCalendars.find(c => c.id === id);
|
||||
if (!cal || !confirm(t('caldav_rotate_confirm'))) return;
|
||||
try {
|
||||
const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`);
|
||||
cal.caldav_url = updated.caldav_url;
|
||||
renderCalendarTable();
|
||||
showToast(t('caldav_token_rotated'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
});
|
||||
});
|
||||
|
||||
// Delete
|
||||
container.querySelectorAll('[data-ct-del]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
@@ -3828,6 +3940,8 @@ function bindSettingsModal() {
|
||||
const body = { email: email || null };
|
||||
if (displayName) body.display_name = displayName;
|
||||
if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
|
||||
const dh = document.getElementById('cfg-directory-hidden');
|
||||
if (dh) body.directory_hidden = dh.checked;
|
||||
try {
|
||||
const res = await api.put('/profile/', body);
|
||||
if (res && res.access_token) localStorage.setItem('token', res.access_token);
|
||||
@@ -4091,6 +4205,55 @@ function bindProfileModal() {
|
||||
};
|
||||
}
|
||||
|
||||
// ── App passwords (CalDAV) — lives in Settings → Profile ──
|
||||
function initAppPasswords() {
|
||||
const appPwList = document.getElementById('app-pw-list');
|
||||
if (!appPwList) return;
|
||||
const newBox = document.getElementById('app-pw-new');
|
||||
newBox.classList.add('hidden');
|
||||
document.getElementById('app-pw-new-value').textContent = '';
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const rows = await api.get('/profile/app-passwords');
|
||||
appPwList.innerHTML = rows.length
|
||||
? rows.map(r => `<div class="app-pw-item">
|
||||
<span class="app-pw-name">${escHtml(r.label)}</span>
|
||||
<span class="app-pw-meta">${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString() : t('app_pw_never_used')}</span>
|
||||
<button class="btn btn-ghost btn-sm app-pw-del" data-id="${r.id}">${t('app_pw_revoke')}</button>
|
||||
</div>`).join('')
|
||||
: `<p class="text-muted">${t('app_pw_none')}</p>`;
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
load();
|
||||
|
||||
document.getElementById('app-pw-create-btn').onclick = async () => {
|
||||
const label = document.getElementById('app-pw-label').value.trim() || 'CalDAV';
|
||||
try {
|
||||
const res = await api.post('/profile/app-passwords', { label });
|
||||
document.getElementById('app-pw-new-value').textContent = res.password;
|
||||
newBox.classList.remove('hidden');
|
||||
document.getElementById('app-pw-label').value = '';
|
||||
load();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
};
|
||||
|
||||
document.getElementById('app-pw-copy').onclick = () => {
|
||||
const v = document.getElementById('app-pw-new-value').textContent;
|
||||
if (v) navigator.clipboard.writeText(v).then(() => showToast(t('app_pw_copied')));
|
||||
};
|
||||
|
||||
appPwList.onclick = async (e) => {
|
||||
const btn = e.target.closest('.app-pw-del');
|
||||
if (!btn) return;
|
||||
if (!confirm(t('app_pw_revoke_confirm'))) return;
|
||||
try {
|
||||
await api.delete(`/profile/app-passwords/${btn.dataset.id}`);
|
||||
load();
|
||||
} catch (err) { showToast(err.message, true); }
|
||||
};
|
||||
}
|
||||
|
||||
function updateTopbarAvatar(hasAvatar) {
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
if (hasAvatar) {
|
||||
|
||||
@@ -91,6 +91,8 @@ const translations = {
|
||||
settings_privacy: 'Privatsphäre',
|
||||
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
||||
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
||||
settings_directory_hidden: 'Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)',
|
||||
settings_directory_hidden_desc: 'Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.',
|
||||
private_visibility_busy: 'Als „Beschäftigt“ anzeigen',
|
||||
private_visibility_hidden: 'Ausblenden',
|
||||
created_by: 'Erstellt von: {name}',
|
||||
@@ -114,6 +116,28 @@ const translations = {
|
||||
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
||||
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
||||
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
||||
copy: 'Kopieren',
|
||||
caldav_publish: 'CalDAV veröffentlichen',
|
||||
caldav_unpublish: 'CalDAV deaktivieren',
|
||||
caldav_published_url: 'CalDAV-URL (ohne Login):',
|
||||
caldav_url_copied: 'CalDAV-URL kopiert',
|
||||
caldav_token_rotate: 'Token neu generieren',
|
||||
caldav_token_rotated: 'Neuer Token erzeugt – alte Abos müssen neu eingerichtet werden',
|
||||
caldav_rotate_confirm: 'Neuen Token erzeugen? Die bisherige URL wird ungültig und bestehende Abos müssen mit der neuen URL neu eingerichtet werden.',
|
||||
caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten – kein Login nötig. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.',
|
||||
caldav_login_url: 'CalDAV-URL (mit Login):',
|
||||
caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und App-Passwort hinzufügen – dann werden alle deine veröffentlichten Kalender gefunden.',
|
||||
app_pw_title: 'App-Passwörter (CalDAV)',
|
||||
app_pw_desc: 'Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.',
|
||||
app_pw_label_ph: 'Name (z.B. iPhone)',
|
||||
app_pw_create: 'Erstellen',
|
||||
app_pw_new_label: 'Neues App-Passwort (nur jetzt sichtbar):',
|
||||
app_pw_last_used: 'zuletzt',
|
||||
app_pw_never_used: 'noch nie genutzt',
|
||||
app_pw_revoke: 'Widerrufen',
|
||||
app_pw_none: 'Noch keine App-Passwörter.',
|
||||
app_pw_copied: 'App-Passwort kopiert',
|
||||
app_pw_revoke_confirm: 'Dieses App-Passwort widerrufen? Clients, die es nutzen, verlieren den Zugriff.',
|
||||
share: 'Teilen',
|
||||
import: 'Importieren',
|
||||
export: 'Exportieren',
|
||||
@@ -384,6 +408,8 @@ const translations = {
|
||||
settings_privacy: 'Privacy',
|
||||
settings_private_visibility: 'Private events for group members',
|
||||
settings_private_visibility_desc: 'How your private events appear to other group members',
|
||||
settings_directory_hidden: 'Hide my profile (don\'t show me in share/group pickers)',
|
||||
settings_directory_hidden_desc: 'Other users then can\'t pick you to share calendars with or add you to groups. You remain visible in the admin user management.',
|
||||
private_visibility_busy: 'Show as "Busy"',
|
||||
private_visibility_hidden: 'Hide completely',
|
||||
created_by: 'Created by: {name}',
|
||||
@@ -407,6 +433,28 @@ const translations = {
|
||||
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
||||
calendar_reminders_on: 'Reminders enabled',
|
||||
calendar_reminders_off: 'Reminders disabled',
|
||||
copy: 'Copy',
|
||||
caldav_publish: 'Publish via CalDAV',
|
||||
caldav_unpublish: 'Unpublish CalDAV',
|
||||
caldav_published_url: 'CalDAV URL (no login):',
|
||||
caldav_url_copied: 'CalDAV URL copied',
|
||||
caldav_token_rotate: 'Regenerate token',
|
||||
caldav_token_rotated: 'New token generated – existing subscriptions must be re-added',
|
||||
caldav_rotate_confirm: 'Generate a new token? The current URL will stop working and existing subscriptions must be re-added with the new URL.',
|
||||
caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar — no login required. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).',
|
||||
caldav_login_url: 'CalDAV URL (with login):',
|
||||
caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and app password — it will discover all your published calendars.',
|
||||
app_pw_title: 'App passwords (CalDAV)',
|
||||
app_pw_desc: "Dedicated passwords for CalDAV clients. Required when 2FA is on, since apps can't enter a 2FA code. Revocable anytime.",
|
||||
app_pw_label_ph: 'Name (e.g. iPhone)',
|
||||
app_pw_create: 'Create',
|
||||
app_pw_new_label: 'New app password (shown only now):',
|
||||
app_pw_last_used: 'last used',
|
||||
app_pw_never_used: 'never used',
|
||||
app_pw_revoke: 'Revoke',
|
||||
app_pw_none: 'No app passwords yet.',
|
||||
app_pw_copied: 'App password copied',
|
||||
app_pw_revoke_confirm: 'Revoke this app password? Clients using it will lose access.',
|
||||
share: 'Share',
|
||||
import: 'Import',
|
||||
export: 'Export',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v62';
|
||||
export const APP_VERSION = 'v74';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// the entry HTML / version files). New releases take effect on the next
|
||||
// reload, no manual SW unregister required.
|
||||
|
||||
const CACHE_VERSION = 'calendarr-v23';
|
||||
const CACHE_VERSION = 'calendarr-v24';
|
||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
|
||||
Reference in New Issue
Block a user