Compare commits

...

21 Commits

Author SHA1 Message Date
Scarriffle
b8a578cf53 refactor(web): use server per-user colour for shared calendars
Replace the per-device localStorage colour override with the server-backed
per-user colour: the colour dot on a shared calendar now PUTs /calendars/{id}/color
(stores the recipient's own colour server-side, synced across devices) and the
list/events already carry share.color from the server, so the localStorage
re-apply layer is removed. Owners and recipients share one code path. v74.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:22:33 +02:00
Scarriffle
572172c424 fix(web): stay logged in when app initialisation fails after reload
boot() ran token validation and launchApp() inside the SAME try/catch, so ANY
error during app init (a failed calendar/event fetch, a render error) cleared
the token and bounced a validly-authenticated user to the login screen — the
"logged out on every F5" bug. Now /auth/me validates the token alone; launchApp()
runs outside that catch, so a data/render error can no longer log the user out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:22:33 +02:00
Scarriffle
343f7a5e7b feat(sharing): recipients can recolour a shared calendar (per-user, no rename)
A share recipient couldn't change anything on a shared calendar (update_calendar
is owner-only → 404), yet the clients still showed a colour picker for it.

Add a per-recipient colour: new nullable calendar_shares.color column (+ migration).
New PUT /calendars/{id}/color endpoint sets the calendar colour for the owner
(global) or, for a recipient, only their own share colour — never the name, so
recipients can recolour but not rename. The merge read and the calendar list now
prefer the recipient's share colour over the owner's (NULL = owner's colour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:43:10 +02:00
Scarriffle
cad48efcc6 feat(web): recolour calendars shared with me (per-device local override)
Shared calendars belong to someone else, so a server colour PUT would 403.
Instead store the recipient's chosen colour locally (localStorage 'sharedCalColors',
keyed by calendar id) and re-apply it to events on every fetch. The colour dot
on a shared calendar now opens the picker instead of showing an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:49:07 +02:00
Scarriffle
2ae8499247 fix(web): can't edit calendars shared with me; struck-pencil read-only marker
- Sidebar: don't start an inline rename on a calendar I don't own (owned=false)
  — the save would only 403. Colour picker was already gated.
- Sidebar: show a struck-through pencil icon on read-only shared calendars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:01 +02:00
Scarriffle
79fdf4f54a feat(sharing): group-shared calendars in every member's sidebar, person share picker, hidden profiles
- Backend: co-member group_visible calendars now surface in /local/calendars
  (owned=false, shared_by=owner, read-only, group_shared) and in the normal
  /caldav/events merge (via readable_local_calendar_ids), deduped against
  direct shares / group calendars so nothing appears twice.
- Backend: new User.directory_hidden — a user can hide from sharing/group
  pickers (/users/directory), while admin user management (/users/) still lists
  them. Migration + profile GET/PUT.
- Backend: /groups/{id} members carry shares_calendar so clients can drop
  phantom rows for members who share nothing.
- Frontend: reachable "Teilen" button on owned local calendars; share modal is
  now a checkbox multi-select of users (checked = shared). Hidden-profile toggle
  in Settings → Profile. Group member filter only lists members who actually
  share (phantom fix). Calendars shared with me moved to a dedicated read-only
  "shared with me" section in the manage table.
- Tests: group_visible propagation, no-share absence, dedup, directory_hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:35:11 +02:00
Scarriffle
f76d2783d9 feat(web): show shared calendars under the owner's name
A calendar shared with another person now appears to the recipient under the
OWNER's name (Guido's "Persönlich" shows as "Guido"), matching the iOS filter
sheet. The original calendar name moves to the sub-label / settings source
column so it stays identifiable. Sidebar list + settings table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:41:31 +02:00
Scarriffle
e539508bec fix(security): scope CalDAV PUT to its calendar, block iCal SSRF, auth avatar endpoint
- dav_router: PUT now looks up the event within the authenticated calendar only
  (local_events.uid is globally unique), so a CalDAV client can no longer
  overwrite another user's/calendar's event; a cross-calendar UID clash returns
  409 instead of a 500 from the UNIQUE constraint.
- ical_router: _fetch_ics validates the URL (http/https only), resolves the host
  and rejects private/loopback/link-local/reserved targets, follows redirects
  manually re-validating each hop, and caps the response size — closing an
  authenticated SSRF into internal services / cloud metadata.
- profile_router: GET /profile/avatar/{user_id} now requires authentication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:17:24 +02:00
Scarriffle
784c9013eb feat(web): mark read-only shared calendars and hide them from the event editor
- Sidebar: a calendar shared with read access shows "· Nur lesen" next to the
  "shared with me · <owner>" label.
- Event editor calendar picker: exclude read-only shared calendars (own +
  read_write, incl. group calendars, stay) so a save can't 403.

Parity with the server/iOS/Android sharing changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:02:12 +02:00
Scarriffle
e3844294ae feat(sharing): label shared personal calendars by owner + drop group title prefix
Two group/sharing display fixes, both server-side so every client benefits:

1. A personal calendar shared WITH a user showed only its raw name
   ("Persönlich"), indistinguishable from the user's own. The merge read now
   relabels a shared *personal* calendar under the owner's display name (so
   Guido's "Persönlich" reads as "Guido" for recipients) and adds read_only:true
   when the share isn't read_write. Group calendars are excluded — they keep
   their own name and stay writable for members.

2. The combined group view prefixed every foreign event with the owner's first
   name ("Guido: …"). Each member already has a distinct display_color, so the
   prefix was redundant. _decorate_title now returns the raw title; display_title
   stays non-empty so clients' legacy prefix fallback never triggers. The change
   takes effect on already-installed clients with no app update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:42:26 +02:00
Scarriffle
444772c959 fix: attribute account-level token failures to each calendar
When a Home Assistant or Google token refresh fails, the whole account fetch
aborts. Previously this raised, so the outer handler in caldav_router emitted
a single sync error WITH NO calendar_id. Clients that preserve cached events
per calendar (iOS) couldn't attribute it and wiped the affected calendars.

Now both get_ha_events and get_google_events catch the token failure and emit
one error per enabled calendar, each carrying its calendar_id — so every
client can pin the failure to a specific calendar and keep its cached data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:31:26 +02:00
Scarriffle
e0ea16f2ef fix: include calendar_id in per-calendar sync errors
Events already carry calendar_id, but the corresponding sync-error
entries didn't, forcing clients to match errors to calendars by
fragile name-suffix comparison. Account-level failures (whole account
unreachable) still omit calendar_id since no single calendar is at fault.
2026-07-02 18:36:46 +02:00
Scarriffle
9fb350eb29 fix: surface all calendar sync failures, not just Google
CalDAV and Home Assistant sync failures were previously only logged
server-side, leaving clients unable to distinguish an empty calendar
from a broken sync. Unify error reporting across CalDAV, Home
Assistant, and Google into a single errors list on GET
/api/caldav/events, shaped as {source, name, message}. Messages are
fixed generic strings, never raw exception text, to avoid leaking
URLs or credential fragments. get_ha_events and get_google_events now
return (events, errors) tuples so per-calendar failures propagate to
the caller in addition to account-level failures. Frontend toast now
picks its label from err.source instead of assuming Google/err.email.
2026-07-02 18:22:11 +02:00
Scarriffle
94655ce7c5 fix(caldav): match Basic Auth username case-insensitively
Login names are stored lowercase and the web login already compares with
func.lower(); CalDAV Basic Auth used an exact match, so "Scarriffle" failed to
authenticate while "scarriffle" worked. Compare case-insensitively too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:38:21 +02:00
Scarriffle
9306d638ad fix(caldav): move app-password section to Settings → Profile where users look
The app-password UI was in the user-menu profile modal, but users manage CalDAV
in Settings, so it went unnoticed. Move the section into the Settings → Profile
panel (next to account/privacy) and drive it from openSettingsModal via a
top-level initAppPasswords().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:17:25 +02:00
Scarriffle
f662163185 feat(caldav): app-specific passwords so MFA accounts can use CalDAV
CalDAV clients send only user+password over Basic Auth and can't provide a TOTP
code, so account passwords would bypass 2FA. Add revocable app passwords:

- models: AppPassword table (bcrypt hash, label, last_used); auto-created via
  create_all
- profile_router: GET/POST/DELETE /profile/app-passwords (plaintext shown once)
- dav_router: Basic Auth accepts any app password; the account password is
  accepted only when 2FA is disabled
- frontend: "App-Passwörter (CalDAV)" section in the profile modal (create/show-
  once/copy/revoke) + i18n (de/en); login hint now says app password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:15:35 +02:00
Scarriffle
fb32f0424f feat(caldav): username/password (Basic Auth) access with discovery + https URLs
- dav_router: add Basic-Auth principal-discovery tree at /caldav/ (and
  /.well-known/caldav) so clients can add a CalDAV account with server URL +
  username + password; lists all published calendars. Token URL /dav/{token}/
  still works without login. Handlers generalised over a base href.
- dav_util: derive the public origin from X-Forwarded-Proto/-Host (or
  PUBLIC_BASE_URL) so published URLs are https, not internal http:8080.
- local_router: expose caldav_login_url alongside caldav_url.
- frontend/i18n: show both the no-login token URL and the login URL + hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:44:16 +02:00
Scarriffle
34e701fa78 feat(pwa): auto-update service worker so new releases take effect without manual cache clearing
- app.js: reload once on SW controllerchange (guarded against loops / first
  install) and poll reg.update() hourly for long-open tabs
- sw.js: bump cache to v24 so the new (network-first) worker replaces any stale
  cache-first worker and cleans old caches on activate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:24:05 +02:00
Scarriffle
661a8ea579 chore: bump APP_VERSION to v65 (cache-bust / deploy verification)
Fresh commit so a git pull visibly advances the deployed version and the PWA
service worker refreshes its cached assets. No functional change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:12:21 +02:00
Scarriffle
47944ed952 feat: publish local calendars via two-way CalDAV (opt-in, secret token URL)
Backend:
- LocalCalendar gains caldav_published + dav_token + dav_ctag; LocalEvent gains
  etag (migrations in main.py). bump_dav() refreshes ctag/etag on every local
  event write (create/update/delete/import).
- local_router: PUT /calendars/{id} accepts caldav_published (mints/revokes
  token), new POST /calendars/{id}/dav-token/rotate, _cal_dict exposes
  caldav_published + caldav_url.
- New dav_router mounted at root (/dav/{token}/...): a minimal two-way CalDAV
  server (OPTIONS/PROPFIND/REPORT/GET/PUT/DELETE) reusing ical_io build/parse,
  ctag-based change detection. Secret token = auth, no login.

Frontend:
- Settings calendar table: per-local-calendar publish toggle + subscribe URL
  with copy and token-rotate; i18n (de/en) and styling.

Note: reverse proxy must allow WebDAV methods (PROPFIND/REPORT/PUT/DELETE).
VALARM/reminders are not round-tripped via CalDAV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:19:01 +02:00
Scarriffle
a591eb51d3 chore: bump APP_VERSION to v63 to ship mini-cal + week-view fixes
The multi-day mini-calendar and short-event week-view fixes were already
committed but not visible because the PWA served cached assets. Bump the
version so clients pick up the new JS/CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:15:41 +02:00
21 changed files with 1443 additions and 69 deletions

63
backend/dav_util.py Normal file
View 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/"

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View 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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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 ─────────────────────────────────────────

View File

@@ -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)
resp.raise_for_status() except socket.gaierror:
resp.encoding = 'utf-8' return False
return resp.text for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return False
return True
def _validate_public_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Nur http(s)-URLs sind erlaubt.")
if not parsed.hostname:
raise ValueError("Ungültige URL.")
if not _host_is_public(parsed.hostname):
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
def _fetch_ics(url: str) -> str:
"""Download .ics content, blocking SSRF to internal/private hosts.
Redirects are followed manually so every hop is re-validated (a public URL
can otherwise 30x-redirect into the internal network). Response size is
capped. (Residual risk: DNS rebinding between check and connect.)
"""
if url.startswith("webcal://"):
url = "https://" + url[len("webcal://"):]
try:
for _ in range(_MAX_REDIRECTS + 1):
_validate_public_url(url)
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True)
if resp.is_redirect and resp.headers.get("location"):
url = urljoin(url, resp.headers["location"])
resp.close()
continue
resp.raise_for_status()
resp.encoding = "utf-8"
chunks, total = [], 0
for chunk in resp.iter_content(8192, decode_unicode=True):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total > _MAX_ICS_BYTES:
resp.close()
raise ValueError("Datei zu groß (max. 5 MB).")
return "".join(chunks)
raise ValueError("Zu viele Weiterleitungen.")
except http_requests.RequestException as e: except http_requests.RequestException as e:
raise ValueError(f"Fehler beim Abrufen der URL: {e}") raise ValueError(f"Fehler beim Abrufen der URL: {e}")

View File

@@ -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:

View File

@@ -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}

View File

@@ -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()
) )

View File

@@ -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())

View File

@@ -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;

View File

@@ -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">

View File

@@ -23,14 +23,23 @@ async function boot() {
// Check if already logged in // Check if already logged in
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
let authed = false;
try { try {
await api.get('/auth/me'); // validate token await api.get('/auth/me'); // validate the TOKEN only
await launchApp(); authed = true;
return;
} catch (_) { } catch (_) {
// The token is genuinely invalid/expired — clear it and show login.
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
} }
if (authed) {
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
// try/catch on purpose: a later data/render error inside app init must
// never bounce a validly-authenticated user back to the login screen
// (that was the "logged out on every reload" bug).
await launchApp();
return;
}
} }
showScreen('login'); showScreen('login');
@@ -192,8 +201,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);
}); });
}); });

View File

@@ -305,6 +305,7 @@ 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 */ }
} }
async function fetchAndRender(force = false, silent = false) { async function fetchAndRender(force = false, silent = false) {
const { start, end } = getViewRange(); const { start, end } = getViewRange();
@@ -377,8 +378,10 @@ async function fetchAndRender(force = false, silent = false) {
const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`); const 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);
} }
} }
eventCache.start = fetchStart; eventCache.start = fetchStart;
@@ -726,9 +729,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: 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 +794,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,10 +906,12 @@ 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; }
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 }); // Colour-only endpoint: the owner sets the calendar's colour, a share
// recipient sets their OWN per-user colour (server-side, synced across
// devices) — without write access or the ability to rename.
await api.put(`/local/calendars/${calId}/color`, { color: picked });
if (cal) cal.color = picked; if (cal) cal.color = picked;
applyCalendarColor('local', calId, picked); applyCalendarColor('local', calId, picked);
} }
@@ -950,6 +960,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 +1591,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 +2692,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 +2823,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 +3142,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 +3478,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 +3594,22 @@ function renderCalendarTable() {
} }
} }
// Calendars shared WITH me (direct shares + group-shared) — read-only, no
// management actions since I don't own them. Shown under the owner's name.
const sharedWithMe = state.localCalendars.filter(c => c.owned === false && !c.group);
if (sharedWithMe.length) {
rows += `<tr class="ct-section-row"><td colspan="7">${t('shared_with_me')}</td></tr>`;
for (const cal of sharedWithMe) {
const readOnly = cal.permission !== 'read_write';
rows += `<tr>
<td>${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}</td>
<td class="ct-src">${escHtml(cal.name)} <span class="cal-badge">${readOnly ? t('perm_read') : t('perm_read_write')}</span></td>
<td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
</tr>`;
rowCount++;
}
}
if (!rowCount) { 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 +3727,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 +3940,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 +4205,55 @@ function bindProfileModal() {
}; };
} }
// ── App passwords (CalDAV) — lives in Settings → Profile ──
function initAppPasswords() {
const appPwList = document.getElementById('app-pw-list');
if (!appPwList) return;
const newBox = document.getElementById('app-pw-new');
newBox.classList.add('hidden');
document.getElementById('app-pw-new-value').textContent = '';
const load = async () => {
try {
const rows = await api.get('/profile/app-passwords');
appPwList.innerHTML = rows.length
? rows.map(r => `<div class="app-pw-item">
<span class="app-pw-name">${escHtml(r.label)}</span>
<span class="app-pw-meta">${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString() : t('app_pw_never_used')}</span>
<button class="btn btn-ghost btn-sm app-pw-del" data-id="${r.id}">${t('app_pw_revoke')}</button>
</div>`).join('')
: `<p class="text-muted">${t('app_pw_none')}</p>`;
} catch (e) { /* ignore */ }
};
load();
document.getElementById('app-pw-create-btn').onclick = async () => {
const label = document.getElementById('app-pw-label').value.trim() || 'CalDAV';
try {
const res = await api.post('/profile/app-passwords', { label });
document.getElementById('app-pw-new-value').textContent = res.password;
newBox.classList.remove('hidden');
document.getElementById('app-pw-label').value = '';
load();
} catch (e) { showToast(e.message, true); }
};
document.getElementById('app-pw-copy').onclick = () => {
const v = document.getElementById('app-pw-new-value').textContent;
if (v) navigator.clipboard.writeText(v).then(() => showToast(t('app_pw_copied')));
};
appPwList.onclick = async (e) => {
const btn = e.target.closest('.app-pw-del');
if (!btn) return;
if (!confirm(t('app_pw_revoke_confirm'))) return;
try {
await api.delete(`/profile/app-passwords/${btn.dataset.id}`);
load();
} catch (err) { showToast(err.message, true); }
};
}
function updateTopbarAvatar(hasAvatar) { function updateTopbarAvatar(hasAvatar) {
const avatar = document.getElementById('user-avatar'); const avatar = document.getElementById('user-avatar');
if (hasAvatar) { if (hasAvatar) {

View File

@@ -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',

View File

@@ -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 = 'v74';

View File

@@ -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 => {