Einige kleine verbesserungen #1
@@ -44,6 +44,53 @@ def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None)
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def private_visibility_for(db: Session, user_id: int) -> str:
|
||||||
|
"""A user's chosen visibility for their private events ('hidden' | 'busy')."""
|
||||||
|
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
|
||||||
|
return (s.private_event_visibility if s else None) or "busy"
|
||||||
|
|
||||||
|
|
||||||
|
# Only these fields survive 'busy' anonymisation — a whitelist, so no content
|
||||||
|
# field (title/location/description/creator/calendar name/recurrence) can leak.
|
||||||
|
_BUSY_KEEP = {
|
||||||
|
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
|
||||||
|
"source", "type", "owner", "is_group_event", "display_color",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mask_busy_event(event: dict) -> dict:
|
||||||
|
"""Anonymise a private event for 'busy' visibility: keep only timing /
|
||||||
|
identity / render fields, drop ALL content."""
|
||||||
|
masked = {k: event[k] for k in _BUSY_KEEP if k in event}
|
||||||
|
masked["title"] = "Beschäftigt"
|
||||||
|
masked["location"] = ""
|
||||||
|
masked["description"] = ""
|
||||||
|
masked["calendar_name"] = ""
|
||||||
|
masked["creator"] = None
|
||||||
|
masked["color"] = None
|
||||||
|
masked["rrule"] = None
|
||||||
|
masked["exdate"] = None
|
||||||
|
masked["private"] = True
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
def apply_event_privacy(
|
||||||
|
event: dict, *, owner_id, is_private: bool, requester_id: int, visibility: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Enforce another user's private-event visibility on a built event dict.
|
||||||
|
|
||||||
|
Returns the event unchanged for the requester's own events or non-private
|
||||||
|
events, ``None`` when the owner chose 'hidden', or a busy-masked copy when
|
||||||
|
the owner chose 'busy'. Used by BOTH the merge read and the combined view so
|
||||||
|
the privacy rule can never drift between them.
|
||||||
|
"""
|
||||||
|
if not is_private or owner_id == requester_id:
|
||||||
|
return event
|
||||||
|
if visibility == "hidden":
|
||||||
|
return None
|
||||||
|
return mask_busy_event(event)
|
||||||
|
|
||||||
|
|
||||||
def build_local_event_dict(
|
def build_local_event_dict(
|
||||||
ev: models.LocalEvent,
|
ev: models.LocalEvent,
|
||||||
cal: models.LocalCalendar,
|
cal: models.LocalCalendar,
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import models
|
|||||||
import permissions
|
import permissions
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from local_events_util import build_local_event_dict, expand_recurring_local, resolve_creator
|
from local_events_util import (
|
||||||
|
apply_event_privacy,
|
||||||
|
build_local_event_dict,
|
||||||
|
expand_recurring_local,
|
||||||
|
private_visibility_for,
|
||||||
|
resolve_creator,
|
||||||
|
)
|
||||||
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
|
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -335,6 +341,14 @@ 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()}
|
||||||
|
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||||
|
vis_cache: dict = {}
|
||||||
|
|
||||||
|
def vis_for(uid: int) -> str:
|
||||||
|
if uid not in vis_cache:
|
||||||
|
vis_cache[uid] = private_visibility_for(db, uid)
|
||||||
|
return vis_cache[uid]
|
||||||
|
|
||||||
for local_cal in local_calendars:
|
for local_cal in local_calendars:
|
||||||
local_events = (
|
local_events = (
|
||||||
db.query(models.LocalEvent)
|
db.query(models.LocalEvent)
|
||||||
@@ -351,10 +365,27 @@ def get_events(
|
|||||||
)
|
)
|
||||||
for ev in local_events:
|
for ev in local_events:
|
||||||
creator = resolve_creator(ev, name_cache=name_cache)
|
creator = resolve_creator(ev, name_cache=name_cache)
|
||||||
|
# A private event belonging to someone else (shared calendar or group
|
||||||
|
# calendar) must honour that owner's private_event_visibility, exactly
|
||||||
|
# like the group combined view — otherwise private titles/locations
|
||||||
|
# leak through the ordinary calendar read.
|
||||||
|
owner_id = ev.creator_id or local_cal.user_id
|
||||||
|
is_priv = bool(ev.is_private)
|
||||||
|
foreign_private = is_priv and owner_id != current_user.id
|
||||||
|
visibility = vis_for(owner_id) if foreign_private else "busy"
|
||||||
|
if foreign_private and visibility == "hidden":
|
||||||
|
continue
|
||||||
if ev.rrule:
|
if ev.rrule:
|
||||||
all_events.extend(expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator))
|
built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator)
|
||||||
else:
|
else:
|
||||||
all_events.append(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:
|
||||||
|
b = apply_event_privacy(
|
||||||
|
b, owner_id=owner_id, is_private=is_priv,
|
||||||
|
requester_id=current_user.id, visibility=visibility,
|
||||||
|
)
|
||||||
|
if b is not None:
|
||||||
|
all_events.append(b)
|
||||||
|
|
||||||
# ── iCal subscription events ──────────────────────────
|
# ── iCal subscription events ──────────────────────────
|
||||||
ical_subs = (
|
ical_subs = (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from sqlalchemy.orm import Session
|
|||||||
import models
|
import models
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from local_events_util import build_local_event_dict, expand_recurring_local
|
from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -309,16 +309,6 @@ def delete_group(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _strip_busy(event: dict) -> dict:
|
|
||||||
"""Anonymise a private event for the 'busy' visibility mode."""
|
|
||||||
event = dict(event)
|
|
||||||
event["title"] = "Beschäftigt"
|
|
||||||
event["location"] = ""
|
|
||||||
event["description"] = ""
|
|
||||||
event["private"] = True
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def _first_name(name: Optional[str]) -> str:
|
def _first_name(name: Optional[str]) -> str:
|
||||||
if not name:
|
if not name:
|
||||||
return ""
|
return ""
|
||||||
@@ -415,7 +405,7 @@ def combined_events(
|
|||||||
|
|
||||||
for b in built:
|
for b in built:
|
||||||
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
|
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
|
||||||
b = _strip_busy(b)
|
b = mask_busy_event(b)
|
||||||
# Colour to render with: the group calendar's colour for group
|
# Colour to render with: the group calendar's colour for group
|
||||||
# events, otherwise the owning member's group colour.
|
# events, otherwise the owning member's group colour.
|
||||||
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
|
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
|
||||||
|
|||||||
@@ -443,6 +443,17 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
|
|||||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
# Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM).
|
||||||
|
MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_capped(file: UploadFile) -> bytes:
|
||||||
|
raw = await file.read(MAX_ICS_BYTES + 1)
|
||||||
|
if len(raw) > MAX_ICS_BYTES:
|
||||||
|
raise HTTPException(413, "Datei zu groß (max. 5 MB)")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
@router.post("/calendars/{calendar_id}/import")
|
@router.post("/calendars/{calendar_id}/import")
|
||||||
async def import_calendar(
|
async def import_calendar(
|
||||||
calendar_id: int,
|
calendar_id: int,
|
||||||
@@ -451,7 +462,7 @@ async def import_calendar(
|
|||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||||
raw = await file.read()
|
raw = await _read_capped(file)
|
||||||
try:
|
try:
|
||||||
return _import_ics_into(cal, raw, db)
|
return _import_ics_into(cal, raw, db)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -467,10 +478,12 @@ async def import_generic(
|
|||||||
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),
|
||||||
):
|
):
|
||||||
|
# Read (capped) first so an oversized upload can't leave an empty calendar.
|
||||||
|
raw = await _read_capped(file)
|
||||||
if create_calendar:
|
if create_calendar:
|
||||||
cal = models.LocalCalendar(
|
cal = models.LocalCalendar(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
name=calendar_name or "Importiert",
|
name=(calendar_name or "Importiert")[:120],
|
||||||
)
|
)
|
||||||
db.add(cal)
|
db.add(cal)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -480,7 +493,6 @@ async def import_generic(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
|
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
|
||||||
|
|
||||||
raw = await file.read()
|
|
||||||
try:
|
try:
|
||||||
result = _import_ics_into(cal, raw, db)
|
result = _import_ics_into(cal, raw, db)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import base64
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -8,7 +9,7 @@ import qrcode
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -27,9 +28,16 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
|||||||
|
|
||||||
# ── Schemas ───────────────────────────────────────────────
|
# ── Schemas ───────────────────────────────────────────────
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
email: Optional[str] = None
|
# Length caps (SQLite ignores VARCHAR limits, so enforce here).
|
||||||
display_name: Optional[str] = None
|
email: Optional[str] = Field(default=None, max_length=120)
|
||||||
username: Optional[str] = None # login name (stored lowercase)
|
display_name: Optional[str] = Field(default=None, max_length=80)
|
||||||
|
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_controls(s: str) -> str:
|
||||||
|
"""Remove control characters (defends against injected newlines / NULs that
|
||||||
|
could be reflected in other clients' calendar/sharing/group views)."""
|
||||||
|
return re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||||
|
|
||||||
|
|
||||||
class PasswordChange(BaseModel):
|
class PasswordChange(BaseModel):
|
||||||
@@ -67,12 +75,26 @@ def update_profile(
|
|||||||
):
|
):
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
current_user.email = data.email or None
|
email = _strip_controls(data.email)
|
||||||
|
if email:
|
||||||
|
if "@" not in email or "." not in email.split("@")[-1]:
|
||||||
|
raise HTTPException(422, "Invalid email address")
|
||||||
|
clash = (
|
||||||
|
db.query(models.User)
|
||||||
|
.filter(func.lower(models.User.email) == email.lower(),
|
||||||
|
models.User.id != current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if clash:
|
||||||
|
raise HTTPException(400, "Email already in use")
|
||||||
|
current_user.email = email
|
||||||
|
else:
|
||||||
|
current_user.email = None
|
||||||
if data.display_name is not None:
|
if data.display_name is not None:
|
||||||
dn = data.display_name.strip()
|
dn = _strip_controls(data.display_name)
|
||||||
current_user.display_name = dn or current_user.username
|
current_user.display_name = dn or current_user.username
|
||||||
if data.username is not None:
|
if data.username is not None:
|
||||||
new_login = data.username.strip().lower()
|
new_login = _strip_controls(data.username).lower()
|
||||||
if not new_login:
|
if not new_login:
|
||||||
raise HTTPException(422, "Login name cannot be empty")
|
raise HTTPException(422, "Login name cannot be empty")
|
||||||
if new_login != current_user.username:
|
if new_login != current_user.username:
|
||||||
|
|||||||
Reference in New Issue
Block a user