inital commit, very early alpha stage
This commit is contained in:
2
backend/app/services/__init__.py
Normal file
2
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Application services."""
|
||||
|
||||
194
backend/app/services/auth.py
Normal file
194
backend/app/services/auth.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, session_expiry, token_urlsafe, utc_now
|
||||
from app.db.base import get_db
|
||||
from app.models import (
|
||||
AppSession,
|
||||
AuditLog,
|
||||
HomeDevice,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberDevice,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentContext:
|
||||
session: AppSession | None
|
||||
home_profile: HomeProfile | None
|
||||
member: Member | None
|
||||
home_device: HomeDevice | None
|
||||
member_device: MemberDevice | None
|
||||
|
||||
@property
|
||||
def authenticated(self) -> bool:
|
||||
return self.session is not None
|
||||
|
||||
|
||||
def _expired(dt: datetime) -> bool:
|
||||
now = utc_now()
|
||||
if dt.tzinfo is None:
|
||||
return dt < now.replace(tzinfo=None)
|
||||
return dt < now
|
||||
|
||||
|
||||
def create_session(
|
||||
db: Session,
|
||||
*,
|
||||
home_profile: HomeProfile | None = None,
|
||||
member: Member | None = None,
|
||||
home_device: HomeDevice | None = None,
|
||||
member_device: MemberDevice | None = None,
|
||||
) -> tuple[AppSession, str]:
|
||||
csrf_token = token_urlsafe(24)
|
||||
session = AppSession(
|
||||
home_profile_id=home_profile.id if home_profile else None,
|
||||
member_id=member.id if member else None,
|
||||
home_device_id=home_device.id if home_device else None,
|
||||
member_device_id=member_device.id if member_device else None,
|
||||
csrf_token_hash=hash_token(csrf_token),
|
||||
expires_at=session_expiry(),
|
||||
)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
return session, csrf_token
|
||||
|
||||
|
||||
def set_session_cookies(response: Response, session: AppSession, csrf_token: str) -> None:
|
||||
settings = get_settings()
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
session.id,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
"grouphome_csrf",
|
||||
csrf_token,
|
||||
httponly=False,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookies(response: Response) -> None:
|
||||
settings = get_settings()
|
||||
response.delete_cookie(settings.session_cookie_name, path="/")
|
||||
response.delete_cookie("grouphome_csrf", path="/")
|
||||
|
||||
|
||||
def load_context_from_request(request: Request, db: Session) -> CurrentContext:
|
||||
settings = get_settings()
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
return CurrentContext(None, None, None, None, None)
|
||||
session = db.get(AppSession, session_id)
|
||||
if not session or session.revoked_at or _expired(session.expires_at):
|
||||
return CurrentContext(None, None, None, None, None)
|
||||
|
||||
home_profile = db.get(HomeProfile, session.home_profile_id) if session.home_profile_id else None
|
||||
member = db.get(Member, session.member_id) if session.member_id else None
|
||||
home_device = db.get(HomeDevice, session.home_device_id) if session.home_device_id else None
|
||||
member_device = db.get(MemberDevice, session.member_device_id) if session.member_device_id else None
|
||||
|
||||
now = utc_now()
|
||||
if home_device and not home_device.revoked_at:
|
||||
home_device.last_seen_at = now
|
||||
if member_device and not member_device.revoked_at:
|
||||
member_device.last_seen_at = now
|
||||
db.flush()
|
||||
|
||||
return CurrentContext(session, home_profile, member, home_device, member_device)
|
||||
|
||||
|
||||
def get_optional_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
|
||||
return load_context_from_request(request, db)
|
||||
|
||||
|
||||
def get_current_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
|
||||
ctx = load_context_from_request(request, db)
|
||||
if not ctx.authenticated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "not_authenticated", "message": "Open an invite or recover access to continue.", "details": {}}},
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
def get_members_for_context(db: Session, ctx: CurrentContext) -> list[Member]:
|
||||
if ctx.home_profile:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Member).where(
|
||||
Member.home_profile_id == ctx.home_profile.id,
|
||||
Member.status.in_(["joined", "verified"]),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if ctx.member:
|
||||
return [ctx.member]
|
||||
return []
|
||||
|
||||
|
||||
def get_member_for_group(db: Session, ctx: CurrentContext, group_id: str) -> Member | None:
|
||||
if ctx.member and ctx.member.group_id == group_id and ctx.member.status != "left":
|
||||
return ctx.member
|
||||
if ctx.home_profile:
|
||||
return db.scalar(
|
||||
select(Member).where(
|
||||
Member.home_profile_id == ctx.home_profile.id,
|
||||
Member.group_id == group_id,
|
||||
Member.status.in_(["joined", "verified"]),
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_home_profile(db: Session, ctx: CurrentContext, display_name: str | None = None) -> HomeProfile:
|
||||
if ctx.home_profile:
|
||||
return ctx.home_profile
|
||||
name = display_name or (ctx.member.display_name if ctx.member else "GroupHome member")
|
||||
profile = HomeProfile(primary_display_name=name)
|
||||
db.add(profile)
|
||||
db.flush()
|
||||
if ctx.member and ctx.member.home_profile_id is None:
|
||||
ctx.member.home_profile_id = profile.id
|
||||
if ctx.session:
|
||||
ctx.session.home_profile_id = profile.id
|
||||
ctx.home_profile = profile
|
||||
db.flush()
|
||||
return profile
|
||||
|
||||
|
||||
def audit(
|
||||
db: Session,
|
||||
*,
|
||||
ctx: CurrentContext | None = None,
|
||||
action: str,
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_member_id=ctx.member.id if ctx and ctx.member else None,
|
||||
actor_home_profile_id=ctx.home_profile.id if ctx and ctx.home_profile else None,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details_json=details or {},
|
||||
)
|
||||
)
|
||||
341
backend/app/services/dashboard.py
Normal file
341
backend/app/services/dashboard.py
Normal file
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import utc_now
|
||||
from app.models import (
|
||||
Announcement,
|
||||
Event,
|
||||
EventRsvp,
|
||||
FileAsset,
|
||||
Group,
|
||||
Member,
|
||||
Poll,
|
||||
PollOption,
|
||||
PollVote,
|
||||
RemoteCachedObject,
|
||||
RemoteServerConnection,
|
||||
Task,
|
||||
Thread,
|
||||
)
|
||||
from app.services.auth import CurrentContext, get_members_for_context
|
||||
from app.services.serializers import (
|
||||
action_dict,
|
||||
announcement_dict,
|
||||
event_dict,
|
||||
file_dict,
|
||||
poll_dict,
|
||||
remote_connection_dict,
|
||||
task_dict,
|
||||
thread_dict,
|
||||
)
|
||||
|
||||
|
||||
PRIORITY_ORDER = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
|
||||
|
||||
|
||||
def _db_now():
|
||||
return utc_now().replace(tzinfo=None)
|
||||
|
||||
|
||||
def _member_rsvp_status(db: Session, event_id: str, member_id: str) -> str:
|
||||
rsvp = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event_id, EventRsvp.member_id == member_id))
|
||||
return rsvp.status if rsvp else "unknown"
|
||||
|
||||
|
||||
def _local_actions_for_member(db: Session, member: Member) -> list[dict[str, Any]]:
|
||||
group = db.get(Group, member.group_id)
|
||||
if not group:
|
||||
return []
|
||||
now = _db_now()
|
||||
actions: list[dict[str, Any]] = []
|
||||
|
||||
events = db.scalars(select(Event).where(Event.group_id == group.id, Event.starts_at >= now - timedelta(days=1))).all()
|
||||
for event in events:
|
||||
status = _member_rsvp_status(db, event.id, member.id)
|
||||
if event.rsvp_required and status == "unknown":
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:rsvp:{member.id}:{event.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="rsvp_required",
|
||||
priority="urgent" if event.starts_at <= now + timedelta(days=2) else "high",
|
||||
title=f"RSVP: {event.title}",
|
||||
summary=event.location_name or "Let the group know if you can make it.",
|
||||
object_type="event",
|
||||
object_id=event.id,
|
||||
due_at=event.starts_at,
|
||||
)
|
||||
)
|
||||
if event.changed_at and (not member.last_seen_at or event.changed_at > member.last_seen_at):
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:event_changed:{member.id}:{event.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="event_changed",
|
||||
priority="high",
|
||||
title=f"Changed: {event.title}",
|
||||
summary="Time or location changed since your last visit.",
|
||||
object_type="event",
|
||||
object_id=event.id,
|
||||
due_at=event.starts_at,
|
||||
)
|
||||
)
|
||||
|
||||
tasks = db.scalars(select(Task).where(Task.assigned_to_member_id == member.id, Task.status == "open")).all()
|
||||
for task in tasks:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:task:{member.id}:{task.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="task_assigned",
|
||||
priority="high" if task.due_at and task.due_at <= now + timedelta(days=3) else "normal",
|
||||
title=task.title,
|
||||
summary=task.description or "Task assigned to you.",
|
||||
object_type="task",
|
||||
object_id=task.id,
|
||||
due_at=task.due_at,
|
||||
)
|
||||
)
|
||||
|
||||
polls = db.scalars(select(Poll).where(Poll.group_id == group.id, Poll.status == "open")).all()
|
||||
for poll in polls:
|
||||
existing_vote = db.scalar(select(PollVote).where(PollVote.poll_id == poll.id, PollVote.member_id == member.id))
|
||||
if not existing_vote:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:poll:{member.id}:{poll.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="vote_required",
|
||||
priority="high" if poll.closes_at and poll.closes_at <= now + timedelta(days=2) else "normal",
|
||||
title=poll.title,
|
||||
summary=poll.description or "Add your vote.",
|
||||
object_type="poll",
|
||||
object_id=poll.id,
|
||||
due_at=poll.closes_at,
|
||||
)
|
||||
)
|
||||
|
||||
files = db.scalars(select(FileAsset).where(FileAsset.group_id == group.id, FileAsset.requires_ack.is_(True))).all()
|
||||
for file in files:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:file_ack:{member.id}:{file.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="file_ack",
|
||||
priority="normal",
|
||||
title=f"Read: {file.filename_original}",
|
||||
summary=file.description or "Please review this file.",
|
||||
object_type="file",
|
||||
object_id=file.id,
|
||||
due_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
announcements = db.scalars(
|
||||
select(Announcement).where(Announcement.group_id == group.id, Announcement.requires_ack.is_(True), Announcement.official.is_(True))
|
||||
).all()
|
||||
for announcement in announcements:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:announcement_ack:{member.id}:{announcement.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="admin_request",
|
||||
priority="urgent" if announcement.priority == "urgent" else "normal",
|
||||
title=announcement.title,
|
||||
summary=announcement.body[:180],
|
||||
object_type="announcement",
|
||||
object_id=announcement.id,
|
||||
due_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def local_actions_for_context(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
for member in get_members_for_context(db, ctx):
|
||||
actions.extend(_local_actions_for_member(db, member))
|
||||
return sort_actions(actions)
|
||||
|
||||
|
||||
def sort_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return sorted(
|
||||
actions,
|
||||
key=lambda item: (
|
||||
PRIORITY_ORDER.get(item.get("priority", "normal"), 9),
|
||||
item.get("due_at") or "9999-12-31T00:00:00",
|
||||
item.get("title") or "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def remote_items_for_context(db: Session, ctx: CurrentContext, object_type: str | None = None) -> list[dict[str, Any]]:
|
||||
if not ctx.home_profile:
|
||||
return []
|
||||
connections = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
|
||||
connection_ids = [connection.id for connection in connections]
|
||||
if not connection_ids:
|
||||
return []
|
||||
query = select(RemoteCachedObject).where(RemoteCachedObject.remote_connection_id.in_(connection_ids))
|
||||
if object_type:
|
||||
query = query.where(RemoteCachedObject.object_type == object_type)
|
||||
rows = db.scalars(query).all()
|
||||
connection_by_id = {connection.id: connection for connection in connections}
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
payload = dict(row.payload_json or {})
|
||||
connection = connection_by_id.get(row.remote_connection_id)
|
||||
payload.setdefault("source_type", "remote")
|
||||
payload.setdefault("source_server_origin", connection.server_origin if connection else "")
|
||||
payload.setdefault("source_group_id", row.group_remote_id)
|
||||
payload.setdefault("source_group_name", row.group_name)
|
||||
payload.setdefault("remote_connection_id", row.remote_connection_id)
|
||||
items.append(payload)
|
||||
return items
|
||||
|
||||
|
||||
def home_dashboard(db: Session, ctx: CurrentContext) -> dict[str, Any]:
|
||||
members = get_members_for_context(db, ctx)
|
||||
member_group_ids = [member.group_id for member in members]
|
||||
now = _db_now()
|
||||
|
||||
local_actions = local_actions_for_context(db, ctx)
|
||||
remote_actions = remote_items_for_context(db, ctx, "action")
|
||||
needs_me = sort_actions(local_actions + remote_actions)
|
||||
|
||||
today: list[dict[str, Any]] = []
|
||||
if member_group_ids:
|
||||
events = db.scalars(
|
||||
select(Event)
|
||||
.where(Event.group_id.in_(member_group_ids), Event.starts_at >= now - timedelta(hours=6))
|
||||
.order_by(Event.starts_at)
|
||||
.limit(50)
|
||||
).all()
|
||||
member_by_group = {member.group_id: member for member in members}
|
||||
for event in events:
|
||||
group = db.get(Group, event.group_id)
|
||||
member = member_by_group.get(event.group_id)
|
||||
today.append(event_dict(event, group, _member_rsvp_status(db, event.id, member.id) if member else None))
|
||||
today.extend(remote_items_for_context(db, ctx, "event"))
|
||||
today = sorted(today, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")[:50]
|
||||
|
||||
changed = [action for action in needs_me if action["type"] in {"event_changed", "admin_request"}][:20]
|
||||
|
||||
official_updates: list[dict[str, Any]] = []
|
||||
if member_group_ids:
|
||||
announcements = db.scalars(
|
||||
select(Announcement)
|
||||
.where(Announcement.group_id.in_(member_group_ids), Announcement.official.is_(True))
|
||||
.order_by(desc(Announcement.created_at))
|
||||
.limit(20)
|
||||
).all()
|
||||
for announcement in announcements:
|
||||
official_updates.append(announcement_dict(announcement, db.get(Group, announcement.group_id)))
|
||||
official_updates.extend(remote_items_for_context(db, ctx, "announcement"))
|
||||
|
||||
discussion_count = 0
|
||||
if member_group_ids:
|
||||
discussion_count = db.scalar(select(func.count()).select_from(Thread).where(Thread.group_id.in_(member_group_ids), Thread.kind == "discussion")) or 0
|
||||
|
||||
catch_up = [
|
||||
{"label": "official announcements", "count": len(official_updates)},
|
||||
{"label": "changed events", "count": len([item for item in changed if item["type"] == "event_changed"])},
|
||||
{"label": "open actions", "count": len(needs_me)},
|
||||
{"label": "discussion threads", "count": discussion_count + len(remote_items_for_context(db, ctx, "thread"))},
|
||||
]
|
||||
|
||||
connections = []
|
||||
if ctx.home_profile:
|
||||
connections = [
|
||||
remote_connection_dict(connection)
|
||||
for connection in db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
|
||||
]
|
||||
|
||||
return {
|
||||
"sections": {
|
||||
"needs_me": needs_me,
|
||||
"today": today,
|
||||
"changed": changed,
|
||||
"official_updates": official_updates,
|
||||
"catch_up": catch_up,
|
||||
},
|
||||
"connections": connections,
|
||||
}
|
||||
|
||||
|
||||
def calendar_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
|
||||
items = home_dashboard(db, ctx)["sections"]["today"]
|
||||
return sorted(items, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")
|
||||
|
||||
|
||||
def file_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
|
||||
members = get_members_for_context(db, ctx)
|
||||
group_ids = [member.group_id for member in members]
|
||||
files: list[dict[str, Any]] = []
|
||||
if group_ids:
|
||||
for file in db.scalars(select(FileAsset).where(FileAsset.group_id.in_(group_ids)).order_by(desc(FileAsset.created_at))).all():
|
||||
files.append(file_dict(file, db.get(Group, file.group_id)))
|
||||
files.extend(remote_items_for_context(db, ctx, "file"))
|
||||
return files
|
||||
|
||||
|
||||
def group_dashboard(db: Session, group: Group, member: Member | None = None) -> dict[str, Any]:
|
||||
rsvp_member_id = member.id if member else ""
|
||||
announcements = [
|
||||
announcement_dict(item, group)
|
||||
for item in db.scalars(
|
||||
select(Announcement).where(Announcement.group_id == group.id).order_by(desc(Announcement.created_at)).limit(20)
|
||||
).all()
|
||||
]
|
||||
events = [
|
||||
event_dict(item, group, _member_rsvp_status(db, item.id, rsvp_member_id) if rsvp_member_id else None)
|
||||
for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(30)).all()
|
||||
]
|
||||
tasks = [task_dict(item, group) for item in db.scalars(select(Task).where(Task.group_id == group.id).order_by(Task.status, Task.due_at)).all()]
|
||||
files = [file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id).order_by(desc(FileAsset.created_at))).all()]
|
||||
|
||||
polls = []
|
||||
for poll in db.scalars(select(Poll).where(Poll.group_id == group.id).order_by(desc(Poll.created_at))).all():
|
||||
options = db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all()
|
||||
votes = db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all()
|
||||
polls.append(poll_dict(poll, list(options), list(votes), group))
|
||||
|
||||
threads = [
|
||||
thread_dict(item, group=group)
|
||||
for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at)).limit(20)).all()
|
||||
]
|
||||
important_now = _local_actions_for_member(db, member) if member else []
|
||||
return {
|
||||
"important_now": sort_actions(important_now)[:8],
|
||||
"upcoming": events[:8],
|
||||
"open_actions": sort_actions(important_now),
|
||||
"announcements": announcements,
|
||||
"tasks": tasks,
|
||||
"polls": polls,
|
||||
"files": files,
|
||||
"discussions": threads,
|
||||
}
|
||||
39
backend/app/services/passkeys.py
Normal file
39
backend/app/services/passkeys.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.security import token_urlsafe
|
||||
|
||||
|
||||
class PasskeyProvider:
|
||||
def registration_options(self, display_name: str | None = None) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_registration(self, payload: dict) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def login_options(self) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_login(self, payload: dict) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DevelopmentPasskeyProvider(PasskeyProvider):
|
||||
"""Development-only passkey-shaped adapter.
|
||||
|
||||
It preserves the API contract and UI flow without claiming production WebAuthn.
|
||||
"""
|
||||
|
||||
def registration_options(self, display_name: str | None = None) -> dict:
|
||||
return {"challenge": token_urlsafe(24), "display_name": display_name, "development_only": True}
|
||||
|
||||
def verify_registration(self, payload: dict) -> dict:
|
||||
return {"verified": True, "trust_level": "passkey_ready", "development_only": True}
|
||||
|
||||
def login_options(self) -> dict:
|
||||
return {"challenge": token_urlsafe(24), "development_only": True}
|
||||
|
||||
def verify_login(self, payload: dict) -> dict:
|
||||
return {"verified": True, "development_only": True}
|
||||
|
||||
|
||||
passkey_provider: PasskeyProvider = DevelopmentPasskeyProvider()
|
||||
41
backend/app/services/permissions.py
Normal file
41
backend/app/services/permissions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import Member
|
||||
|
||||
|
||||
ROLE_ORDER = {
|
||||
"guest": 0,
|
||||
"member": 10,
|
||||
"moderator": 20,
|
||||
"admin": 30,
|
||||
"owner": 40,
|
||||
}
|
||||
|
||||
|
||||
def has_role(member: Member | None, min_role: str) -> bool:
|
||||
if member is None:
|
||||
return False
|
||||
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER.get(min_role, 999)
|
||||
|
||||
|
||||
def require_role(member: Member | None, min_role: str = "admin") -> None:
|
||||
if not has_role(member, min_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": {"code": "permission_denied", "message": "You do not have permission to do that.", "details": {}}},
|
||||
)
|
||||
|
||||
|
||||
def can(member: Member | None, action: str, resource: object | None = None) -> bool:
|
||||
if member is None:
|
||||
return False
|
||||
if action in {"rsvp", "vote", "comment", "upload_file", "view_group"}:
|
||||
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER["member"]
|
||||
if action == "create_official_announcement":
|
||||
return has_role(member, "moderator")
|
||||
if action in {"create_invite", "view_migration", "manage_members", "create_connection_token"}:
|
||||
return has_role(member, "admin")
|
||||
if action in {"create_event", "create_task"}:
|
||||
return has_role(member, "moderator")
|
||||
return False
|
||||
|
||||
175
backend/app/services/remote.py
Normal file
175
backend/app/services/remote.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, utc_now
|
||||
from app.models import ConnectionToken, RemoteCachedObject, RemoteServerConnection, RemoteSyncCursor
|
||||
from app.services.dashboard import home_dashboard
|
||||
|
||||
|
||||
def mask_store_token(raw_token: str) -> str:
|
||||
return f"dev:{raw_token}"
|
||||
|
||||
|
||||
def unmask_store_token(stored: str) -> str:
|
||||
if stored.startswith("dev:"):
|
||||
return stored[4:]
|
||||
return stored
|
||||
|
||||
|
||||
def validate_connection_token(db: Session, raw_token: str) -> ConnectionToken | None:
|
||||
token = db.scalar(select(ConnectionToken).where(ConnectionToken.token_hash == hash_token(raw_token)))
|
||||
if not token or token.revoked_at:
|
||||
return None
|
||||
if token.expires_at:
|
||||
now = utc_now()
|
||||
expires = token.expires_at if token.expires_at.tzinfo else token.expires_at.replace(tzinfo=now.tzinfo)
|
||||
if expires < now:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def manifest() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"server_name": settings.server_name,
|
||||
"api_base": settings.api_base_url,
|
||||
"protocol_version": "0.1",
|
||||
"capabilities": {
|
||||
"events": True,
|
||||
"tasks": True,
|
||||
"files": True,
|
||||
"chat": True,
|
||||
"polls": True,
|
||||
"federation": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sync_payload_for_token(db: Session, token: ConnectionToken | None) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
fake_ctx = type("SyncContext", (), {"home_profile": None, "member": None, "session": None})()
|
||||
payload = home_dashboard(db, fake_ctx) if False else None
|
||||
actions: list[dict[str, Any]] = []
|
||||
events: list[dict[str, Any]] = []
|
||||
announcements: list[dict[str, Any]] = []
|
||||
files: list[dict[str, Any]] = []
|
||||
threads: list[dict[str, Any]] = []
|
||||
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.models import Announcement, Event, FileAsset, Group, Poll, Task, Thread
|
||||
from app.services.dashboard import _local_actions_for_member
|
||||
from app.services.serializers import announcement_dict, event_dict, file_dict, thread_dict
|
||||
|
||||
group_filter = [token.group_id] if token and token.group_id else [group.id for group in db.scalars(select(Group)).all()]
|
||||
groups = [db.get(Group, group_id) for group_id in group_filter]
|
||||
for group in [item for item in groups if item]:
|
||||
for member in group.members:
|
||||
if member.status in {"joined", "verified"}:
|
||||
actions.extend(_local_actions_for_member(db, member))
|
||||
break
|
||||
events.extend([event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id)).all()])
|
||||
announcements.extend(
|
||||
[announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True))).all()]
|
||||
)
|
||||
files.extend([file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id)).all()])
|
||||
threads.extend(
|
||||
[thread_dict(item, group=group) for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at))).all()]
|
||||
)
|
||||
|
||||
for collection in (actions, events, announcements, files, threads):
|
||||
for item in collection:
|
||||
item["source_type"] = "remote"
|
||||
item["source_server_origin"] = settings.server_origin
|
||||
|
||||
return {
|
||||
"cursor": utc_now().isoformat(),
|
||||
"server_time": utc_now().isoformat(),
|
||||
"actions": actions,
|
||||
"events": events,
|
||||
"announcements": announcements,
|
||||
"files": files,
|
||||
"threads": threads,
|
||||
}
|
||||
|
||||
|
||||
def fetch_manifest(server_url: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
|
||||
response = client.get(f"{server_url.rstrip('/')}/.well-known/group-platform.json")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def sync_connection(db: Session, connection: RemoteServerConnection) -> RemoteServerConnection:
|
||||
settings = get_settings()
|
||||
cursor = db.scalar(select(RemoteSyncCursor).where(RemoteSyncCursor.remote_connection_id == connection.id))
|
||||
since = cursor.cursor if cursor else None
|
||||
params = {"since": since} if since else {}
|
||||
raw_token = unmask_store_token(connection.access_token_encrypted)
|
||||
try:
|
||||
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
|
||||
response = client.get(f"{connection.api_base.rstrip('/')}/sync", params=params, headers={"Authorization": f"Bearer {raw_token}"})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
connection.status = "error"
|
||||
connection.last_error = str(exc)
|
||||
connection.updated_at = utc_now()
|
||||
db.flush()
|
||||
return connection
|
||||
|
||||
for object_type, collection_name in [
|
||||
("action", "actions"),
|
||||
("event", "events"),
|
||||
("announcement", "announcements"),
|
||||
("file", "files"),
|
||||
("thread", "threads"),
|
||||
]:
|
||||
for item in payload.get(collection_name, []):
|
||||
remote_id = str(item.get("id") or item.get("object_id") or f"{object_type}:{len(item)}")
|
||||
group_remote_id = str(item.get("source_group_id") or item.get("group_id") or "remote")
|
||||
group_name = str(item.get("source_group_name") or item.get("group_name") or connection.server_name)
|
||||
existing = db.scalar(
|
||||
select(RemoteCachedObject).where(
|
||||
RemoteCachedObject.remote_connection_id == connection.id,
|
||||
RemoteCachedObject.object_type == object_type,
|
||||
RemoteCachedObject.remote_id == remote_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
existing.group_remote_id = group_remote_id
|
||||
existing.group_name = group_name
|
||||
existing.payload_json = item
|
||||
existing.cached_at = utc_now()
|
||||
else:
|
||||
db.add(
|
||||
RemoteCachedObject(
|
||||
remote_connection_id=connection.id,
|
||||
object_type=object_type,
|
||||
remote_id=remote_id,
|
||||
group_remote_id=group_remote_id,
|
||||
group_name=group_name,
|
||||
payload_json=item,
|
||||
)
|
||||
)
|
||||
next_cursor = payload.get("cursor")
|
||||
if cursor:
|
||||
cursor.cursor = next_cursor
|
||||
cursor.updated_at = utc_now()
|
||||
else:
|
||||
db.add(RemoteSyncCursor(remote_connection_id=connection.id, cursor=next_cursor))
|
||||
connection.status = "active"
|
||||
connection.last_error = None
|
||||
connection.last_sync_at = utc_now()
|
||||
connection.updated_at = utc_now()
|
||||
db.flush()
|
||||
return connection
|
||||
|
||||
260
backend/app/services/serializers.py
Normal file
260
backend/app/services/serializers.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.models import (
|
||||
Announcement,
|
||||
Event,
|
||||
FileAsset,
|
||||
Group,
|
||||
HomeDevice,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberDevice,
|
||||
Message,
|
||||
Poll,
|
||||
PollOption,
|
||||
PollVote,
|
||||
RemoteServerConnection,
|
||||
Task,
|
||||
Thread,
|
||||
)
|
||||
|
||||
|
||||
def iso(value: datetime | date | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def group_dict(group: Group) -> dict[str, Any]:
|
||||
return {
|
||||
"id": group.id,
|
||||
"server_origin": group.server_origin,
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"visibility": group.visibility,
|
||||
"legacy_channel_status": group.legacy_channel_status,
|
||||
"transition_deadline": iso(group.transition_deadline),
|
||||
"created_at": iso(group.created_at),
|
||||
"updated_at": iso(group.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def member_dict(member: Member) -> dict[str, Any]:
|
||||
return {
|
||||
"id": member.id,
|
||||
"group_id": member.group_id,
|
||||
"home_profile_id": member.home_profile_id,
|
||||
"display_name": member.display_name,
|
||||
"role": member.role,
|
||||
"status": member.status,
|
||||
"joined_at": iso(member.joined_at),
|
||||
"last_seen_at": iso(member.last_seen_at),
|
||||
"notification_enabled_at": iso(member.notification_enabled_at),
|
||||
}
|
||||
|
||||
|
||||
def profile_dict(profile: HomeProfile | None, member: Member | None = None) -> dict[str, Any] | None:
|
||||
if profile:
|
||||
return {
|
||||
"id": profile.id,
|
||||
"primary_display_name": profile.primary_display_name,
|
||||
"status": profile.status,
|
||||
"last_seen_at": iso(profile.last_seen_at),
|
||||
}
|
||||
if member:
|
||||
return {
|
||||
"id": None,
|
||||
"primary_display_name": member.display_name,
|
||||
"status": "membership_only",
|
||||
"last_seen_at": iso(member.last_seen_at),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def announcement_dict(announcement: Announcement, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": announcement.id,
|
||||
"group_id": announcement.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"author_member_id": announcement.author_member_id,
|
||||
"title": announcement.title,
|
||||
"body": announcement.body,
|
||||
"priority": announcement.priority,
|
||||
"official": announcement.official,
|
||||
"requires_ack": announcement.requires_ack,
|
||||
"created_at": iso(announcement.created_at),
|
||||
"updated_at": iso(announcement.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def event_dict(event: Event, group: Group | None = None, rsvp_status: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": event.id,
|
||||
"group_id": event.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"created_by_member_id": event.created_by_member_id,
|
||||
"title": event.title,
|
||||
"description": event.description,
|
||||
"starts_at": iso(event.starts_at),
|
||||
"ends_at": iso(event.ends_at),
|
||||
"location_name": event.location_name,
|
||||
"location_address": event.location_address,
|
||||
"rsvp_required": event.rsvp_required,
|
||||
"rsvp_status": rsvp_status,
|
||||
"changed_at": iso(event.changed_at),
|
||||
"created_at": iso(event.created_at),
|
||||
"updated_at": iso(event.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def task_dict(task: Task, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": task.id,
|
||||
"group_id": task.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"created_by_member_id": task.created_by_member_id,
|
||||
"assigned_to_member_id": task.assigned_to_member_id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"due_at": iso(task.due_at),
|
||||
"status": task.status,
|
||||
"created_at": iso(task.created_at),
|
||||
"updated_at": iso(task.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def poll_dict(poll: Poll, options: list[PollOption], votes: list[PollVote], group: Group | None = None) -> dict[str, Any]:
|
||||
counts: dict[str, int] = {option.id: 0 for option in options}
|
||||
for vote in votes:
|
||||
counts[vote.option_id] = counts.get(vote.option_id, 0) + 1
|
||||
return {
|
||||
"id": poll.id,
|
||||
"group_id": poll.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"title": poll.title,
|
||||
"description": poll.description,
|
||||
"closes_at": iso(poll.closes_at),
|
||||
"status": poll.status,
|
||||
"created_by_member_id": poll.created_by_member_id,
|
||||
"created_at": iso(poll.created_at),
|
||||
"options": [{"id": option.id, "label": option.label, "position": option.position, "vote_count": counts.get(option.id, 0)} for option in options],
|
||||
}
|
||||
|
||||
|
||||
def file_dict(file: FileAsset, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": file.id,
|
||||
"group_id": file.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"uploaded_by_member_id": file.uploaded_by_member_id,
|
||||
"filename_original": file.filename_original,
|
||||
"content_type": file.content_type,
|
||||
"size_bytes": file.size_bytes,
|
||||
"visibility": file.visibility,
|
||||
"description": file.description,
|
||||
"requires_ack": file.requires_ack,
|
||||
"created_at": iso(file.created_at),
|
||||
"download_url": f"/api/files/{file.id}/download",
|
||||
}
|
||||
|
||||
|
||||
def thread_dict(thread: Thread, messages: list[Message] | None = None, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": thread.id,
|
||||
"group_id": thread.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"title": thread.title,
|
||||
"kind": thread.kind,
|
||||
"created_by_member_id": thread.created_by_member_id,
|
||||
"created_at": iso(thread.created_at),
|
||||
"updated_at": iso(thread.updated_at),
|
||||
"messages": [message_dict(message) for message in messages or []],
|
||||
}
|
||||
|
||||
|
||||
def message_dict(message: Message) -> dict[str, Any]:
|
||||
return {
|
||||
"id": message.id,
|
||||
"thread_id": message.thread_id,
|
||||
"author_member_id": message.author_member_id,
|
||||
"body": message.body,
|
||||
"created_at": iso(message.created_at),
|
||||
"edited_at": iso(message.edited_at),
|
||||
"deleted_at": iso(message.deleted_at),
|
||||
}
|
||||
|
||||
|
||||
def action_dict(
|
||||
*,
|
||||
id: str,
|
||||
source_type: str,
|
||||
source_server_origin: str,
|
||||
source_group_id: str,
|
||||
source_group_name: str,
|
||||
type: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
summary: str,
|
||||
object_type: str,
|
||||
object_id: str,
|
||||
due_at: datetime | None = None,
|
||||
status: str = "open",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": id,
|
||||
"source_type": source_type,
|
||||
"source_server_origin": source_server_origin,
|
||||
"source_group_id": source_group_id,
|
||||
"source_group_name": source_group_name,
|
||||
"type": type,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"object_type": object_type,
|
||||
"object_id": object_id,
|
||||
"due_at": iso(due_at),
|
||||
}
|
||||
|
||||
|
||||
def device_dict(device: HomeDevice | MemberDevice, current_id: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": device.id,
|
||||
"label": device.label,
|
||||
"created_at": iso(device.created_at),
|
||||
"last_seen_at": iso(device.last_seen_at),
|
||||
"revoked_at": iso(device.revoked_at),
|
||||
"trust_level": device.trust_level,
|
||||
"current": device.id == current_id,
|
||||
}
|
||||
|
||||
|
||||
def remote_connection_dict(connection: RemoteServerConnection) -> dict[str, Any]:
|
||||
return {
|
||||
"id": connection.id,
|
||||
"server_origin": connection.server_origin,
|
||||
"server_name": connection.server_name,
|
||||
"api_base": connection.api_base,
|
||||
"protocol_version": connection.protocol_version,
|
||||
"capabilities": connection.capabilities_json,
|
||||
"status": connection.status,
|
||||
"last_sync_at": iso(connection.last_sync_at),
|
||||
"last_error": connection.last_error,
|
||||
"created_at": iso(connection.created_at),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user