Compare commits
19 Commits
master
...
343f7a5e7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -225,6 +225,40 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
_migrate()
|
||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
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(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
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"
|
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class User(Base):
|
|||||||
avatar_filename = Column(String(255), nullable=True)
|
avatar_filename = Column(String(255), nullable=True)
|
||||||
totp_secret = Column(String(32), nullable=True)
|
totp_secret = Column(String(32), nullable=True)
|
||||||
totp_enabled = Column(Boolean, default=False)
|
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(
|
caldav_accounts = relationship(
|
||||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||||
@@ -114,6 +117,26 @@ class UserSettings(Base):
|
|||||||
user = relationship("User", back_populates="settings")
|
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):
|
class LocalCalendar(Base):
|
||||||
__tablename__ = "local_calendars"
|
__tablename__ = "local_calendars"
|
||||||
|
|
||||||
@@ -124,6 +147,12 @@ class LocalCalendar(Base):
|
|||||||
enabled = Column(Boolean, default=True)
|
enabled = Column(Boolean, default=True)
|
||||||
# Whether events of this calendar generate reminders/notifications on clients.
|
# Whether events of this calendar generate reminders/notifications on clients.
|
||||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
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")
|
user = relationship("User", back_populates="local_calendars")
|
||||||
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||||
@@ -152,6 +181,8 @@ class LocalEvent(Base):
|
|||||||
creator_name_external = Column(Text, nullable=True)
|
creator_name_external = Column(Text, nullable=True)
|
||||||
# Private events are filtered for other group members per their visibility setting.
|
# Private events are filtered for other group members per their visibility setting.
|
||||||
is_private = Column(Boolean, default=False)
|
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")
|
calendar = relationship("LocalCalendar", back_populates="events")
|
||||||
creator = relationship("User")
|
creator = relationship("User")
|
||||||
@@ -276,6 +307,7 @@ class CalendarShare(Base):
|
|||||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
permission = Column(String(20), default="read") # 'read' | 'read_write'
|
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
|
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||||
|
|
||||||
calendar = relationship("LocalCalendar")
|
calendar = relationship("LocalCalendar")
|
||||||
|
|||||||
@@ -97,8 +97,44 @@ def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> model
|
|||||||
return cal
|
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]:
|
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()
|
ids: set[int] = set()
|
||||||
|
|
||||||
own = (
|
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)
|
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)
|
return list(ids)
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ def get_events(
|
|||||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
all_events = []
|
all_events = []
|
||||||
|
sync_errors = []
|
||||||
accounts = (
|
accounts = (
|
||||||
db.query(models.CalDAVAccount)
|
db.query(models.CalDAVAccount)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -333,6 +334,12 @@ def get_events(
|
|||||||
logger.error(
|
logger.error(
|
||||||
"Error fetching calendar %s: %s", calendar.id, exc
|
"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) ─────────────
|
# ── Local calendar events (own + shared + group calendars) ─────────────
|
||||||
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
|
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
|
||||||
@@ -345,6 +352,21 @@ def get_events(
|
|||||||
.all()
|
.all()
|
||||||
) if readable_ids else []
|
) if readable_ids else []
|
||||||
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
|
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).
|
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||||
vis_cache: dict = {}
|
vis_cache: dict = {}
|
||||||
|
|
||||||
@@ -354,6 +376,15 @@ def get_events(
|
|||||||
return vis_cache[uid]
|
return vis_cache[uid]
|
||||||
|
|
||||||
for local_cal in local_calendars:
|
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 = (
|
local_events = (
|
||||||
db.query(models.LocalEvent)
|
db.query(models.LocalEvent)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -384,6 +415,12 @@ def get_events(
|
|||||||
else:
|
else:
|
||||||
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
|
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
|
||||||
for b in built:
|
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 = apply_event_privacy(
|
||||||
b, owner_id=owner_id, is_private=is_priv,
|
b, owner_id=owner_id, is_private=is_priv,
|
||||||
requester_id=current_user.id, visibility=visibility,
|
requester_id=current_user.id, visibility=visibility,
|
||||||
@@ -414,13 +451,18 @@ def get_events(
|
|||||||
.filter(models.GoogleAccount.user_id == current_user.id)
|
.filter(models.GoogleAccount.user_id == current_user.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
google_errors = []
|
|
||||||
for g_acc in google_accounts:
|
for g_acc in google_accounts:
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, 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 ─────────────────────────────
|
# ── Home Assistant events ─────────────────────────────
|
||||||
from routers.homeassistant_router import get_ha_events
|
from routers.homeassistant_router import get_ha_events
|
||||||
@@ -431,11 +473,18 @@ def get_events(
|
|||||||
)
|
)
|
||||||
for ha_acc in ha_accounts:
|
for ha_acc in ha_accounts:
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Error fetching HA events for %s: %s", ha_acc.name, 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")
|
@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 ───────────────────────────────────────────────
|
# ── Events ───────────────────────────────────────────────
|
||||||
|
|
||||||
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
|
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."""
|
"""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:
|
try:
|
||||||
token = _refresh_access_token(account, db)
|
token = _refresh_access_token(account, db)
|
||||||
except Exception as exc:
|
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)
|
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:
|
for gcal in account.calendars:
|
||||||
if not gcal.enabled or gcal.sidebar_hidden:
|
if not gcal.enabled or gcal.sidebar_hidden:
|
||||||
continue
|
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"))
|
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, 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):
|
class GoogleEventCreate(BaseModel):
|
||||||
|
|||||||
@@ -176,11 +176,22 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
|
|||||||
member_dicts = []
|
member_dicts = []
|
||||||
for i, m in enumerate(members):
|
for i, m in enumerate(members):
|
||||||
u = db.query(models.User).filter(models.User.id == m.user_id).first()
|
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({
|
member_dicts.append({
|
||||||
"id": m.user_id,
|
"id": m.user_id,
|
||||||
"display_name": (u.display_name or u.username) if u else None,
|
"display_name": (u.display_name or u.username) if u else None,
|
||||||
"role": m.role,
|
"role": m.role,
|
||||||
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
|
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
|
||||||
|
"shares_calendar": shares_calendar,
|
||||||
})
|
})
|
||||||
gcal_id = _group_calendar_id(db, group.id)
|
gcal_id = _group_calendar_id(db, group.id)
|
||||||
return {
|
return {
|
||||||
@@ -317,17 +328,12 @@ def _first_name(name: Optional[str]) -> str:
|
|||||||
|
|
||||||
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
|
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
|
||||||
owner: Optional[dict], me_id: int) -> str:
|
owner: Optional[dict], me_id: int) -> str:
|
||||||
"""Server-side display title for the combined view so every client (web,
|
"""Server-side display title for the combined view. The former owner/creator
|
||||||
iOS, Android) renders identically: another member's / creator's first name
|
first-name prefix ("Guido: …") was dropped: each member already has a
|
||||||
is prefixed. No icon glyph is embedded — group icons are semantic keys the
|
distinct colour, so the prefix was redundant noise. We still return a
|
||||||
clients render as native vector icons, and group-calendar events are
|
non-empty `display_title` (== raw title) so the clients' legacy fallback —
|
||||||
distinguished by their (group) colour. The raw `title` stays for editing."""
|
which rebuilds a prefix when `display_title` is empty — never kicks in.
|
||||||
if is_group:
|
`display_color` continues to carry the per-person colour."""
|
||||||
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}"
|
|
||||||
return title
|
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 = []
|
all_events = []
|
||||||
|
errors = []
|
||||||
try:
|
try:
|
||||||
token = _get_valid_token(account, db)
|
token = _get_valid_token(account, db)
|
||||||
except Exception as exc:
|
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)
|
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:
|
for cal in account.calendars:
|
||||||
if not cal.enabled or cal.sidebar_hidden:
|
if not cal.enabled or cal.sidebar_hidden:
|
||||||
continue
|
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))
|
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, 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 ─────────────────────────────────────────
|
# ── Serialization ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -45,13 +48,67 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_ics(url: str) -> str:
|
_MAX_ICS_BYTES = 5 * 1024 * 1024
|
||||||
"""Download .ics content from a URL."""
|
_MAX_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _host_is_public(host: str) -> bool:
|
||||||
|
"""False if the host resolves to any private/loopback/link-local address."""
|
||||||
try:
|
try:
|
||||||
resp = http_requests.get(url, timeout=30, allow_redirects=True)
|
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.raise_for_status()
|
||||||
resp.encoding = 'utf-8'
|
resp.encoding = "utf-8"
|
||||||
return resp.text
|
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:
|
except http_requests.RequestException as e:
|
||||||
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
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 fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import dav_util
|
||||||
import ical_io
|
import ical_io
|
||||||
import models
|
import models
|
||||||
import permissions
|
import permissions
|
||||||
@@ -31,6 +32,7 @@ class CalendarUpdate(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
reminders_enabled: Optional[bool] = None
|
reminders_enabled: Optional[bool] = None
|
||||||
|
caldav_published: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
@@ -67,16 +69,24 @@ class ShareCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
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 = {
|
d = {
|
||||||
"id": cal.id,
|
"id": cal.id,
|
||||||
"name": cal.name,
|
"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,
|
"enabled": cal.enabled,
|
||||||
"reminders_enabled": bool(cal.reminders_enabled),
|
"reminders_enabled": bool(cal.reminders_enabled),
|
||||||
|
"caldav_published": bool(cal.caldav_published),
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"owned": owned,
|
"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:
|
if shared_by is not None:
|
||||||
d["shared_by"] = shared_by
|
d["shared_by"] = shared_by
|
||||||
if permission is not None:
|
if permission is not None:
|
||||||
@@ -92,6 +102,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -
|
|||||||
|
|
||||||
@router.get("/calendars")
|
@router.get("/calendars")
|
||||||
def list_calendars(
|
def list_calendars(
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -117,7 +128,7 @@ def list_calendars(
|
|||||||
)
|
)
|
||||||
result = []
|
result = []
|
||||||
for c in own:
|
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:
|
if c.id in group_cal_map:
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
||||||
@@ -141,6 +152,7 @@ def list_calendars(
|
|||||||
cal, owned=False,
|
cal, owned=False,
|
||||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
|
color_override=share.color,
|
||||||
)
|
)
|
||||||
if cal.id in group_cal_map:
|
if cal.id in group_cal_map:
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
@@ -158,6 +170,23 @@ def list_calendars(
|
|||||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
result.append(d)
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -182,6 +211,7 @@ def create_calendar(
|
|||||||
def update_calendar(
|
def update_calendar(
|
||||||
calendar_id: int,
|
calendar_id: int,
|
||||||
data: CalendarUpdate,
|
data: CalendarUpdate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -203,8 +233,74 @@ def update_calendar(
|
|||||||
cal.enabled = data.enabled
|
cal.enabled = data.enabled
|
||||||
if data.reminders_enabled is not None:
|
if data.reminders_enabled is not None:
|
||||||
cal.reminders_enabled = data.reminders_enabled
|
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()
|
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}")
|
@router.delete("/calendars/{calendar_id}")
|
||||||
@@ -257,6 +353,7 @@ def create_event(
|
|||||||
creator_id=current_user.id, # server-side, never from the client
|
creator_id=current_user.id, # server-side, never from the client
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
|
dav_util.bump_dav(cal, ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ev)
|
db.refresh(ev)
|
||||||
return _event_dict(ev, cal, db)
|
return _event_dict(ev, cal, db)
|
||||||
@@ -305,6 +402,7 @@ def update_event(
|
|||||||
ev.exdate = ",".join(dates)
|
ev.exdate = ",".join(dates)
|
||||||
if data.reminders is not None:
|
if data.reminders is not None:
|
||||||
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else 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()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -316,6 +414,7 @@ def delete_event(
|
|||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ev = _writable_event(db, current_user, uid)
|
ev = _writable_event(db, current_user, uid)
|
||||||
|
dav_util.bump_dav(ev.calendar)
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -441,9 +540,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
|
|||||||
rrule=item.get("rrule"),
|
rrule=item.get("rrule"),
|
||||||
exdate=item.get("exdate"),
|
exdate=item.get("exdate"),
|
||||||
creator_name_external=item.get("organizer"),
|
creator_name_external=item.get("organizer"),
|
||||||
|
etag=dav_util.new_tag(),
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
imported += 1
|
imported += 1
|
||||||
|
if imported:
|
||||||
|
dav_util.bump_dav(cal)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
import base64
|
import base64
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ class ProfileUpdate(BaseModel):
|
|||||||
email: Optional[str] = Field(default=None, max_length=120)
|
email: Optional[str] = Field(default=None, max_length=120)
|
||||||
display_name: Optional[str] = Field(default=None, max_length=80)
|
display_name: Optional[str] = Field(default=None, max_length=80)
|
||||||
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
|
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:
|
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,
|
"is_admin": current_user.is_admin,
|
||||||
"has_avatar": current_user.avatar_filename is not None,
|
"has_avatar": current_user.avatar_filename is not None,
|
||||||
"totp_enabled": current_user.totp_enabled,
|
"totp_enabled": current_user.totp_enabled,
|
||||||
|
"directory_hidden": bool(current_user.directory_hidden),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,11 +111,15 @@ def update_profile(
|
|||||||
if taken:
|
if taken:
|
||||||
raise HTTPException(400, "Username already taken")
|
raise HTTPException(400, "Username already taken")
|
||||||
current_user.username = new_login
|
current_user.username = new_login
|
||||||
|
if data.directory_hidden is not None:
|
||||||
|
current_user.directory_hidden = data.directory_hidden
|
||||||
db.commit()
|
db.commit()
|
||||||
# The JWT 'sub' is the login name — renaming it invalidates the old
|
# The JWT 'sub' is the login name — renaming it invalidates the old
|
||||||
# token, so hand back a fresh one for the client to store.
|
# token, so hand back a fresh one for the client to store.
|
||||||
result["access_token"] = create_access_token({"sub": new_login})
|
result["access_token"] = create_access_token({"sub": new_login})
|
||||||
return result
|
return result
|
||||||
|
if data.directory_hidden is not None:
|
||||||
|
current_user.directory_hidden = data.directory_hidden
|
||||||
db.commit()
|
db.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -168,7 +176,11 @@ def get_avatar(current_user: models.User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/avatar/{user_id}")
|
@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()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
if not user or not user.avatar_filename:
|
if not user or not user.avatar_filename:
|
||||||
raise HTTPException(404, "Kein Profilbild")
|
raise HTTPException(404, "Kein Profilbild")
|
||||||
@@ -264,3 +276,74 @@ def disable_totp(
|
|||||||
current_user.totp_enabled = False
|
current_user.totp_enabled = False
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
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 = (
|
users = (
|
||||||
db.query(models.User)
|
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)
|
.order_by(models.User.username)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -409,3 +409,78 @@ def test_import_export_only_local(client):
|
|||||||
cal_id = _make_calendar(client, admin, "Privat")
|
cal_id = _make_calendar(client, admin, "Privat")
|
||||||
# Bob has no access -> 404 on export.
|
# Bob has no access -> 404 on export.
|
||||||
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
|
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-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-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||||
.ct-toggle { cursor: pointer; }
|
.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, .ct-bell { opacity: .45; transition: opacity .15s; }
|
||||||
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
|
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
|
||||||
.ct-eye:hover, .ct-bell:hover { opacity: 1; }
|
.ct-eye:hover, .ct-bell:hover { opacity: 1; }
|
||||||
@@ -1986,12 +2002,21 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.share-user-item {
|
.share-user-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.share-user-item:last-child { border-bottom: none; }
|
.share-user-item:last-child { border-bottom: none; }
|
||||||
.share-user-item:hover { background: var(--bg-surface); }
|
.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. */
|
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
|
||||||
|
|
||||||
/* ── Groups ─────────────────────────────────────────────────── */
|
/* ── Groups ─────────────────────────────────────────────────── */
|
||||||
@@ -2123,6 +2148,8 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
/* Group emoji + icon picker */
|
/* Group emoji + icon picker */
|
||||||
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
|
.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; }
|
.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-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.group-icon-opt {
|
.group-icon-opt {
|
||||||
width: 38px; height: 38px;
|
width: 38px; height: 38px;
|
||||||
|
|||||||
@@ -716,6 +716,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
|
<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>
|
<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>
|
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -725,6 +742,13 @@
|
|||||||
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
|
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
|
||||||
<div class="contrast-selector" id="cfg-event-duration">
|
<div class="contrast-selector" id="cfg-event-duration">
|
||||||
|
|||||||
@@ -192,8 +192,23 @@ boot();
|
|||||||
|
|
||||||
// ── Service Worker registration (PWA) ─────────────────────
|
// ── Service Worker registration (PWA) ─────────────────────
|
||||||
if ('serviceWorker' in navigator) {
|
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', () => {
|
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);
|
console.warn('SW registration failed:', err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,6 +305,28 @@ function setGroupColorOverride(groupId, key, hex) {
|
|||||||
try { localStorage.setItem('groupMemberColors', JSON.stringify(all)); } catch (e) { /* ignore */ }
|
try { localStorage.setItem('groupMemberColors', JSON.stringify(all)); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-device colour override for calendars shared WITH me. The calendar belongs
|
||||||
|
// to someone else (server colour is theirs), so the recipient's chosen colour is
|
||||||
|
// stored locally and re-applied on every fetch — keyed by local calendar id.
|
||||||
|
function loadSharedCalColors() {
|
||||||
|
try { return JSON.parse(localStorage.getItem('sharedCalColors') || '{}'); } catch (e) { return {}; }
|
||||||
|
}
|
||||||
|
function sharedCalColorOverride(id) { return loadSharedCalColors()[String(id)] || null; }
|
||||||
|
function setSharedCalColorOverride(id, hex) {
|
||||||
|
const all = loadSharedCalColors();
|
||||||
|
if (hex) all[String(id)] = hex; else delete all[String(id)];
|
||||||
|
try { localStorage.setItem('sharedCalColors', JSON.stringify(all)); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
function applySharedColorOverrides(events) {
|
||||||
|
const overrides = loadSharedCalColors();
|
||||||
|
if (!Object.keys(overrides).length) return;
|
||||||
|
events.forEach(ev => {
|
||||||
|
if (ev.source !== 'local') return;
|
||||||
|
const cid = String(ev.calendar_id).replace('local-', '');
|
||||||
|
if (overrides[cid]) ev.calendarColor = overrides[cid];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAndRender(force = false, silent = false) {
|
async function fetchAndRender(force = false, silent = false) {
|
||||||
const { start, end } = getViewRange();
|
const { start, end } = getViewRange();
|
||||||
|
|
||||||
@@ -377,10 +399,13 @@ async function fetchAndRender(force = false, silent = false) {
|
|||||||
const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
|
const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
|
||||||
const events = resp.events || resp;
|
const events = resp.events || resp;
|
||||||
if (resp.errors && resp.errors.length) {
|
if (resp.errors && resp.errors.length) {
|
||||||
|
const sourceLabels = { caldav: 'CalDAV', homeassistant: 'Home Assistant', google: 'Google' };
|
||||||
for (const err of resp.errors) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applySharedColorOverrides(events);
|
||||||
eventCache.start = fetchStart;
|
eventCache.start = fetchStart;
|
||||||
eventCache.end = fetchEnd;
|
eventCache.end = fetchEnd;
|
||||||
eventCache.events = events;
|
eventCache.events = events;
|
||||||
@@ -726,9 +751,13 @@ function renderCalendarList() {
|
|||||||
remove: { icon: TRASH, title: t('remove_cal') } });
|
remove: { icon: TRASH, title: t('remove_cal') } });
|
||||||
});
|
});
|
||||||
state.localCalendars.filter(c => c.owned === false && !c.group).forEach(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}"`,
|
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||||
name: cal.name, color: cal.color, enabled: cal.enabled,
|
name: cal.shared_by || cal.name, color: sharedCalColorOverride(cal.id) || cal.color, enabled: cal.enabled, readOnly,
|
||||||
sourceLabel: `${t('shared_with_me')} · ${cal.shared_by || ''}`, remove: null });
|
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
|
// Group calendars (owned by the creator or reached via membership) — shown so
|
||||||
// they can be toggled/recoloured. Owned group calendars can also be muted
|
// they can be toggled/recoloured. Owned group calendars can also be muted
|
||||||
@@ -787,6 +816,7 @@ function renderCalendarList() {
|
|||||||
${e.isGroupCal ? `<span class="cal-shared-flag" title="${escHtml(e.sourceLabel)}">${groupIconSvg(e.groupIcon || 'people', 13)}</span>` : ''}
|
${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>` : ''}
|
${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>
|
<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.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>` : ''}
|
${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>`
|
</div>`
|
||||||
@@ -898,7 +928,16 @@ function renderCalendarList() {
|
|||||||
} else if (source === 'local') {
|
} else if (source === 'local') {
|
||||||
const calId = parseInt(dot.dataset.calId);
|
const calId = parseInt(dot.dataset.calId);
|
||||||
const cal = state.localCalendars.find(c => c.id === calId);
|
const cal = state.localCalendars.find(c => c.id === calId);
|
||||||
if (cal && cal.owned === false) { showToast(t('only_owner_color'), true); return; }
|
if (cal && cal.owned === false) {
|
||||||
|
// Shared calendar — belongs to someone else. Apply a per-device local
|
||||||
|
// colour (stored client-side) instead of a server PUT that would 403.
|
||||||
|
const picked = await openColorPicker(dot, sharedCalColorOverride(calId) || cal.color || '#34a853');
|
||||||
|
if (picked) {
|
||||||
|
setSharedCalColorOverride(calId, picked);
|
||||||
|
applyCalendarColor('local', calId, picked); // recolour cached events + re-render
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const picked = await openColorPicker(dot, cal?.color || '#34a853');
|
const picked = await openColorPicker(dot, cal?.color || '#34a853');
|
||||||
if (picked) {
|
if (picked) {
|
||||||
await api.put(`/local/calendars/${calId}`, { color: picked });
|
await api.put(`/local/calendars/${calId}`, { color: picked });
|
||||||
@@ -950,6 +989,12 @@ function renderCalendarList() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const item = nameEl.closest('.cal-item');
|
const item = nameEl.closest('.cal-item');
|
||||||
const source = nameEl.dataset.source;
|
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 currentName = nameEl.textContent;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
@@ -1575,8 +1620,10 @@ function populateCalendarSelect(selectedId) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Local calendars. Group calendars are collected under a "Groups" optgroup
|
// Local calendars. Group calendars are collected under a "Groups" optgroup
|
||||||
// (no emoji — a native <select> can't render the group icon SVG).
|
// (no emoji — a native <select> can't render the group icon SVG). Read-only
|
||||||
const localCals = state.localCalendars.filter(c => !c.sidebar_hidden);
|
// 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 makeLocalOpt = cal => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = `local-${cal.id}`;
|
opt.value = `local-${cal.id}`;
|
||||||
@@ -2674,31 +2721,42 @@ async function refreshShareModal(calendarId) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the directory (minus already-shared users) for the picker.
|
// Store the full directory + which users are already shared, so the picker
|
||||||
document.getElementById('modal-share').__users = users.filter(u => !sharedIds.has(u.id));
|
// can show a checkbox per user (checked = shared).
|
||||||
|
const modal = document.getElementById('modal-share');
|
||||||
|
modal.__users = users;
|
||||||
|
modal.__sharedIds = sharedIds;
|
||||||
renderShareUserPicker();
|
renderShareUserPicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderShareUserPicker() {
|
function renderShareUserPicker() {
|
||||||
const modal = document.getElementById('modal-share');
|
const modal = document.getElementById('modal-share');
|
||||||
const users = modal.__users || [];
|
const users = modal.__users || [];
|
||||||
|
const sharedIds = modal.__sharedIds || new Set();
|
||||||
const q = (document.getElementById('share-user-search').value || '').toLowerCase();
|
const q = (document.getElementById('share-user-search').value || '').toLowerCase();
|
||||||
const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q));
|
const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q));
|
||||||
const picker = document.getElementById('share-user-picker');
|
const picker = document.getElementById('share-user-picker');
|
||||||
picker.innerHTML = filtered.length
|
picker.innerHTML = filtered.length
|
||||||
? filtered.map(u =>
|
? 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('')
|
).join('')
|
||||||
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
|
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
|
||||||
picker.querySelectorAll('.share-user-item').forEach(el => {
|
picker.querySelectorAll('.share-user-cb').forEach(cb => {
|
||||||
el.addEventListener('click', async () => {
|
cb.addEventListener('change', async () => {
|
||||||
const calId = parseInt(modal.dataset.calId);
|
const calId = parseInt(modal.dataset.calId);
|
||||||
const permission = document.getElementById('share-permission').value;
|
const userId = parseInt(cb.dataset.userId);
|
||||||
try {
|
try {
|
||||||
await api.post(`/local/calendars/${calId}/shares`,
|
if (cb.checked) {
|
||||||
{ user_id: parseInt(el.dataset.userId), permission });
|
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);
|
await refreshShareModal(calId);
|
||||||
} catch (e) { showToast(e.message, true); }
|
} catch (e) { showToast(e.message, true); cb.checked = !cb.checked; }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2794,7 +2852,11 @@ function renderGroupMemberFilter() {
|
|||||||
<span class="cal-item-name">${escHtml(name)}</span>
|
<span class="cal-item-name">${escHtml(name)}</span>
|
||||||
</div>`;
|
</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));
|
parts.push(row('gc', 'gc', t('group_calendar'), g.group_calendar_color));
|
||||||
items.innerHTML = parts.join('');
|
items.innerHTML = parts.join('');
|
||||||
|
|
||||||
@@ -3109,7 +3171,10 @@ function openSettingsModal() {
|
|||||||
document.getElementById('cfg-login-name').value = pu.username || '';
|
document.getElementById('cfg-login-name').value = pu.username || '';
|
||||||
api.get('/profile/').then(p => {
|
api.get('/profile/').then(p => {
|
||||||
document.getElementById('cfg-email').value = p.email || '';
|
document.getElementById('cfg-email').value = p.email || '';
|
||||||
|
const dh = document.getElementById('cfg-directory-hidden');
|
||||||
|
if (dh) dh.checked = !!p.directory_hidden;
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
initAppPasswords();
|
||||||
|
|
||||||
// Set active contrast/hour-height/duration buttons
|
// Set active contrast/hour-height/duration buttons
|
||||||
[
|
[
|
||||||
@@ -3442,24 +3507,43 @@ function renderCalendarTable() {
|
|||||||
|
|
||||||
let rows = '';
|
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) {
|
for (const cal of state.localCalendars) {
|
||||||
if (cal.group) continue;
|
if (cal.group || cal.owned === false) continue;
|
||||||
const owned = cal.owned !== false;
|
const pub = !!cal.caldav_published;
|
||||||
const canWrite = owned || cal.permission === 'read_write';
|
|
||||||
rows += `<tr>
|
rows += `<tr>
|
||||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
||||||
<td class="ct-src">Lokal</td>
|
<td class="ct-src">Lokal</td>
|
||||||
<td>—</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>
|
<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>
|
<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>
|
<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>`;
|
</tr>`;
|
||||||
rowCount++;
|
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
|
// iCal subscriptions
|
||||||
@@ -3539,6 +3623,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) {
|
if (!rowCount) {
|
||||||
rows = `<tr><td colspan="7" class="ct-empty">Keine Kalender vorhanden</td></tr>`;
|
rows = `<tr><td colspan="7" class="ct-empty">Keine Kalender vorhanden</td></tr>`;
|
||||||
}
|
}
|
||||||
@@ -3656,6 +3756,47 @@ function renderCalendarTable() {
|
|||||||
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId)));
|
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
|
// Delete
|
||||||
container.querySelectorAll('[data-ct-del]').forEach(btn => {
|
container.querySelectorAll('[data-ct-del]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -3828,6 +3969,8 @@ function bindSettingsModal() {
|
|||||||
const body = { email: email || null };
|
const body = { email: email || null };
|
||||||
if (displayName) body.display_name = displayName;
|
if (displayName) body.display_name = displayName;
|
||||||
if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
|
if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
|
||||||
|
const dh = document.getElementById('cfg-directory-hidden');
|
||||||
|
if (dh) body.directory_hidden = dh.checked;
|
||||||
try {
|
try {
|
||||||
const res = await api.put('/profile/', body);
|
const res = await api.put('/profile/', body);
|
||||||
if (res && res.access_token) localStorage.setItem('token', res.access_token);
|
if (res && res.access_token) localStorage.setItem('token', res.access_token);
|
||||||
@@ -4091,6 +4234,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) {
|
function updateTopbarAvatar(hasAvatar) {
|
||||||
const avatar = document.getElementById('user-avatar');
|
const avatar = document.getElementById('user-avatar');
|
||||||
if (hasAvatar) {
|
if (hasAvatar) {
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ const translations = {
|
|||||||
settings_privacy: 'Privatsphäre',
|
settings_privacy: 'Privatsphäre',
|
||||||
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
||||||
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
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_busy: 'Als „Beschäftigt“ anzeigen',
|
||||||
private_visibility_hidden: 'Ausblenden',
|
private_visibility_hidden: 'Ausblenden',
|
||||||
created_by: 'Erstellt von: {name}',
|
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.',
|
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
||||||
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
||||||
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
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',
|
share: 'Teilen',
|
||||||
import: 'Importieren',
|
import: 'Importieren',
|
||||||
export: 'Exportieren',
|
export: 'Exportieren',
|
||||||
@@ -384,6 +408,8 @@ const translations = {
|
|||||||
settings_privacy: 'Privacy',
|
settings_privacy: 'Privacy',
|
||||||
settings_private_visibility: 'Private events for group members',
|
settings_private_visibility: 'Private events for group members',
|
||||||
settings_private_visibility_desc: 'How your private events appear to other 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_busy: 'Show as "Busy"',
|
||||||
private_visibility_hidden: 'Hide completely',
|
private_visibility_hidden: 'Hide completely',
|
||||||
created_by: 'Created by: {name}',
|
created_by: 'Created by: {name}',
|
||||||
@@ -407,6 +433,28 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
||||||
calendar_reminders_on: 'Reminders enabled',
|
calendar_reminders_on: 'Reminders enabled',
|
||||||
calendar_reminders_off: 'Reminders disabled',
|
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',
|
share: 'Share',
|
||||||
import: 'Import',
|
import: 'Import',
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v62';
|
export const APP_VERSION = 'v73';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// the entry HTML / version files). New releases take effect on the next
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
// reload, no manual SW unregister required.
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v23';
|
const CACHE_VERSION = 'calendarr-v24';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|||||||
Reference in New Issue
Block a user