diff --git a/.env.example b/.env.example index 87875dd..73071e3 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ +APP_BASE_URL=http://kacper-dev-pod:8800 FORGEJO_BASE_URL=https://aksal.cloud FORGEJO_TOKEN= +FORGEJO_OAUTH_CLIENT_ID= +FORGEJO_OAUTH_CLIENT_SECRET= +FORGEJO_OAUTH_SCOPES=openid profile FORGEJO_REPO_SCAN_LIMIT=30 FORGEJO_RECENT_ISSUE_LIMIT=6 FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0 CALENDAR_FEED_URLS= -CALENDAR_EVENT_LIMIT=6 +CALENDAR_EVENT_LIMIT=3 diff --git a/AGENTS.md b/AGENTS.md index 509a3c2..2e9d085 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,10 @@ cp .env.example .env Useful variables: - `FORGEJO_BASE_URL=https://aksal.cloud` +- `APP_BASE_URL=http://kacper-dev-pod:8800` +- `FORGEJO_OAUTH_CLIENT_ID=...` +- `FORGEJO_OAUTH_CLIENT_SECRET=...` +- `FORGEJO_OAUTH_SCOPES=openid profile` - `FORGEJO_TOKEN=...` - `CALENDAR_FEED_URLS=webcal://...` - `HOST=0.0.0.0` @@ -69,7 +73,10 @@ Useful variables: Notes: -- `FORGEJO_TOKEN` is required for live repo and discussion reads on `aksal.cloud`. +- Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL. +- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token server-side and may use it only after enforcing public-repository checks. +- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public. +- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`. - `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds. - Do not commit `.env` or `.env.local`. diff --git a/README.md b/README.md index 973bca0..25cec52 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,34 @@ HOST=0.0.0.0 PORT=8800 ./scripts/start.sh Optional live Forgejo configuration: ```bash +export APP_BASE_URL="http://kacper-dev-pod:8800" export FORGEJO_BASE_URL="https://aksal.cloud" -export FORGEJO_TOKEN="your-forgejo-api-token" +export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id" +export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret" +export FORGEJO_OAUTH_SCOPES="openid profile" export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics" ``` +`APP_BASE_URL` must match the URL you use in the browser. Create the OAuth app in Forgejo with this redirect URI: + +```text +http://kacper-dev-pod:8800/api/auth/forgejo/callback +``` + +`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments. + Or put those values in `.env`: ```bash cp .env.example .env ``` +Sign in through `/signin` using Forgejo OAuth, or query the API directly with: + +```bash +curl -H "Authorization: token your-forgejo-api-token" http://127.0.0.1:8800/api/prototype +``` + ### Frontend ```bash diff --git a/app.py b/app.py index 6fa02dd..6d83e64 100644 --- a/app.py +++ b/app.py @@ -1,14 +1,26 @@ from __future__ import annotations from pathlib import Path +from typing import Any +from urllib.parse import urlencode -from fastapi import FastAPI +from fastapi import Body, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles +from auth import ( + OAuthStateRecord, + clear_login_session, + consume_oauth_state, + create_login_session, + create_oauth_state, + current_session_user, + resolve_forgejo_token, +) +from forgejo_client import ForgejoClient, ForgejoClientError from live_prototype import build_live_prototype_payload -from settings import get_settings +from settings import Settings, get_settings BASE_DIR = Path(__file__).resolve().parent DIST_DIR = BASE_DIR / "frontend" / "dist" @@ -29,8 +41,135 @@ def create_app() -> FastAPI: return JSONResponse({"status": "ok"}) @app.get("/api/prototype") - async def prototype() -> JSONResponse: - return JSONResponse(await build_live_prototype_payload(get_settings())) + async def prototype(request: Request) -> JSONResponse: + settings = get_settings() + session_user = current_session_user(request) + forgejo_token, auth_source = resolve_forgejo_token(request, settings) + return JSONResponse( + await build_live_prototype_payload( + settings, + forgejo_token=forgejo_token, + auth_source=auth_source, + session_user=session_user, + ), + ) + + @app.get("/api/auth/session") + async def auth_session(request: Request) -> JSONResponse: + session_user = current_session_user(request) + if session_user: + return JSONResponse(_auth_payload(session_user, "session")) + + settings = get_settings() + forgejo_token, auth_source = resolve_forgejo_token(request, settings) + if not forgejo_token or auth_source == "server": + return JSONResponse(_auth_payload(None, "none")) + + async with ForgejoClient(settings, forgejo_token=forgejo_token) as client: + try: + user = await client.fetch_current_user() + except ForgejoClientError as error: + raise HTTPException(status_code=401, detail=str(error)) from error + + return JSONResponse(_auth_payload(user, auth_source)) + + @app.get("/api/auth/forgejo/start") + async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse: + settings = get_settings() + if not _oauth_configured(settings): + return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.") + + redirect_uri = _oauth_redirect_uri(request, settings) + state, code_challenge = create_oauth_state(redirect_uri, return_to) + + async with ForgejoClient(settings) as client: + try: + oidc = await client.fetch_openid_configuration() + except ForgejoClientError as error: + return _signin_error_redirect(str(error)) + + authorization_endpoint = str(oidc.get("authorization_endpoint") or "") + if not authorization_endpoint: + return _signin_error_redirect("Forgejo did not return an OAuth authorization endpoint.") + + query = urlencode( + { + "client_id": settings.forgejo_oauth_client_id or "", + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(settings.forgejo_oauth_scopes), + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + }, + ) + return RedirectResponse(f"{authorization_endpoint}?{query}", status_code=303) + + @app.get("/api/auth/forgejo/callback") + async def forgejo_auth_callback( + code: str | None = None, + state: str | None = None, + error: str | None = None, + ) -> RedirectResponse: + if error: + return _signin_error_redirect(f"Forgejo sign-in failed: {error}") + if not code or not state: + return _signin_error_redirect("Forgejo did not return the expected sign-in data.") + + settings = get_settings() + if not _oauth_configured(settings): + return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.") + + oauth_state = consume_oauth_state(state) + if oauth_state is None: + return _signin_error_redirect("The Forgejo sign-in request expired. Try again.") + + try: + access_token = await _exchange_forgejo_code(settings, code, oauth_state) + user = await _fetch_forgejo_oidc_user(settings, access_token) + except ForgejoClientError as exchange_error: + return _signin_error_redirect(str(exchange_error)) + + response = RedirectResponse(oauth_state.return_to, status_code=303) + create_login_session(response, access_token, user) + return response + + @app.delete("/api/auth/session") + async def delete_auth_session(request: Request) -> JSONResponse: + response = JSONResponse(_auth_payload(None, "none")) + clear_login_session(request, response) + return response + + @app.post("/api/discussions/replies") + async def create_discussion_reply( + request: Request, + payload: dict[str, object] = Body(...), + ) -> JSONResponse: + owner = _required_string(payload, "owner") + repo = _required_string(payload, "repo") + body = _required_string(payload, "body") + issue_number = _required_positive_int(payload, "number") + settings = get_settings() + forgejo_token, auth_source = resolve_forgejo_token(request, settings) + if not forgejo_token or auth_source == "server": + raise HTTPException( + status_code=401, + detail="Sign in or send an Authorization token before replying.", + ) + + async with ForgejoClient(settings, forgejo_token=forgejo_token) as client: + try: + repo_payload = await client.fetch_repository(owner, repo) + if repo_payload.get("private"): + raise HTTPException( + status_code=403, + detail="This site only writes to public Forgejo repositories.", + ) + comment = await client.create_issue_comment(owner, repo, issue_number, body) + except ForgejoClientError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + + return JSONResponse(_discussion_reply(comment)) if DIST_DIR.exists(): assets_dir = DIST_DIR / "assets" @@ -53,3 +192,123 @@ def create_app() -> FastAPI: app = create_app() + + +def _required_string(payload: dict[str, object], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise HTTPException(status_code=400, detail=f"{key} is required.") + return value.strip() + + +def _required_positive_int(payload: dict[str, object], key: str) -> int: + value = payload.get(key) + if isinstance(value, bool): + raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.") + + if isinstance(value, int): + parsed = value + elif isinstance(value, str) and value.isdigit(): + parsed = int(value) + else: + raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.") + + if parsed < 1: + raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.") + return parsed + + +def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]: + author = comment.get("user") or {} + body = str(comment.get("body", "") or "").strip() + if not body: + body = "No comment body provided." + + return { + "id": int(comment.get("id", 0)), + "author": author.get("login", "Unknown author"), + "avatar_url": author.get("avatar_url", ""), + "body": body, + "created_at": comment.get("created_at", ""), + "html_url": comment.get("html_url", ""), + } + + +def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]: + oauth_configured = _oauth_configured(get_settings()) + if not user: + return { + "authenticated": False, + "login": None, + "source": source, + "can_reply": source in {"authorization", "session"}, + "oauth_configured": oauth_configured, + } + + return { + "authenticated": True, + "login": user.get("login", "Unknown user"), + "source": source, + "can_reply": source in {"authorization", "session"}, + "oauth_configured": oauth_configured, + } + + +def _oauth_configured(settings: Settings) -> bool: + return bool(settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret) + + +def _oauth_redirect_uri(request: Request, settings: Settings) -> str: + if settings.app_base_url: + return f"{settings.app_base_url}/api/auth/forgejo/callback" + return str(request.url_for("forgejo_auth_callback")) + + +def _signin_error_redirect(message: str) -> RedirectResponse: + return RedirectResponse(f"/signin?{urlencode({'error': message})}", status_code=303) + + +async def _exchange_forgejo_code( + settings: Settings, + code: str, + oauth_state: OAuthStateRecord, +) -> str: + async with ForgejoClient(settings) as client: + oidc = await client.fetch_openid_configuration() + token_endpoint = str(oidc.get("token_endpoint") or "") + if not token_endpoint: + raise ForgejoClientError("Forgejo did not return an OAuth token endpoint.") + + token_payload = await client.exchange_oauth_code( + token_endpoint=token_endpoint, + client_id=settings.forgejo_oauth_client_id or "", + client_secret=settings.forgejo_oauth_client_secret or "", + code=code, + redirect_uri=oauth_state.redirect_uri, + code_verifier=oauth_state.code_verifier, + ) + + access_token = token_payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise ForgejoClientError("Forgejo did not return an access token.") + return access_token + + +async def _fetch_forgejo_oidc_user(settings: Settings, access_token: str) -> dict[str, Any]: + async with ForgejoClient(settings) as client: + oidc = await client.fetch_openid_configuration() + userinfo_endpoint = str(oidc.get("userinfo_endpoint") or "") + if not userinfo_endpoint: + raise ForgejoClientError("Forgejo did not return an OIDC UserInfo endpoint.") + + userinfo = await client.fetch_userinfo(userinfo_endpoint, access_token) + + login = userinfo.get("preferred_username") or userinfo.get("name") or userinfo.get("sub") + if not isinstance(login, str) or not login: + raise ForgejoClientError("Forgejo did not return a usable user identity.") + + return { + "login": login, + "avatar_url": userinfo.get("picture", ""), + "email": userinfo.get("email", ""), + } diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..ddf0be5 --- /dev/null +++ b/auth.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import secrets +import time +from base64 import urlsafe_b64encode +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any + +from fastapi import Request, Response + +from settings import Settings + +SESSION_COOKIE_NAME = "robot_u_session" +SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 14 +OAUTH_STATE_MAX_AGE_SECONDS = 60 * 10 + + +@dataclass +class SessionRecord: + forgejo_token: str | None + user: dict[str, Any] + created_at: float + + +@dataclass +class OAuthStateRecord: + redirect_uri: str + return_to: str + code_verifier: str + created_at: float + + +_SESSIONS: dict[str, SessionRecord] = {} +_OAUTH_STATES: dict[str, OAuthStateRecord] = {} + + +def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | None, str]: + header_token = _authorization_token(request.headers.get("authorization")) + if header_token: + return header_token, "authorization" + + session = _session_from_request(request) + if session and session.forgejo_token: + return session.forgejo_token, "session" + + if settings.forgejo_token: + return settings.forgejo_token, "server" + + return None, "none" + + +def current_session_user(request: Request) -> dict[str, Any] | None: + session = _session_from_request(request) + return session.user if session else None + + +def create_login_session( + response: Response, + forgejo_token: str | None, + user: dict[str, Any], +) -> None: + session_id = secrets.token_urlsafe(32) + _SESSIONS[session_id] = SessionRecord( + forgejo_token=forgejo_token, + user=user, + created_at=time.time(), + ) + response.set_cookie( + SESSION_COOKIE_NAME, + session_id, + httponly=True, + samesite="lax", + max_age=SESSION_MAX_AGE_SECONDS, + path="/", + ) + + +def clear_login_session(request: Request, response: Response) -> None: + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if session_id: + _SESSIONS.pop(session_id, None) + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + + +def create_oauth_state(redirect_uri: str, return_to: str) -> tuple[str, str]: + state = secrets.token_urlsafe(32) + code_verifier = secrets.token_urlsafe(64) + _OAUTH_STATES[state] = OAuthStateRecord( + redirect_uri=redirect_uri, + return_to=_safe_return_path(return_to), + code_verifier=code_verifier, + created_at=time.time(), + ) + return state, code_challenge(code_verifier) + + +def consume_oauth_state(state: str) -> OAuthStateRecord | None: + record = _OAUTH_STATES.pop(state, None) + if not record: + return None + + if time.time() - record.created_at > OAUTH_STATE_MAX_AGE_SECONDS: + return None + return record + + +def code_challenge(code_verifier: str) -> str: + digest = sha256(code_verifier.encode("ascii")).digest() + return urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def _authorization_token(value: str | None) -> str | None: + if not value: + return None + + parts = value.strip().split(None, 1) + if len(parts) == 2 and parts[0].lower() in {"bearer", "token"}: + return parts[1].strip() or None + return None + + +def _safe_return_path(value: str) -> str: + if not value.startswith("/") or value.startswith("//"): + return "/" + return value + + +def _session_from_request(request: Request) -> SessionRecord | None: + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if not session_id: + return None + + session = _SESSIONS.get(session_id) + if not session: + return None + + if time.time() - session.created_at > SESSION_MAX_AGE_SECONDS: + _SESSIONS.pop(session_id, None) + return None + + return session diff --git a/blogs/building-robot-u-site/index.md b/blogs/building-robot-u-site/index.md new file mode 100644 index 0000000..806cd9f --- /dev/null +++ b/blogs/building-robot-u-site/index.md @@ -0,0 +1,70 @@ +--- +title: Building Robot U +summary: How the Robot U community site became a thin, friendly layer over Forgejo. +--- + +# Building Robot U + +Robot U started with a simple goal: give a small robotics community one place to learn, share projects, and ask for help without inventing a second source of truth. + +The site is intentionally built as a thin web layer over Forgejo. Forgejo stores the real community content. Repositories hold courses and posts. Issues hold discussions. OAuth lets members sign in with the same identity they already use for code and collaboration. + +## What The Site Does + +The first prototype focuses on the core community loop: + +- Visitors can read public courses, blog posts, discussions, replies, and upcoming events without signing in. +- Members can sign in with Forgejo OAuth. +- Signed-in members can reply to discussions from the site while still posting as their real Forgejo account. +- Courses are discovered from public repositories that contain a `lessons/` folder. +- Blog content is discovered from public repositories that contain a `blogs/` folder. +- Discussions are backed by Forgejo issues, so the Forgejo API remains the durable storage layer. +- Events are loaded from an ICS calendar feed. + +That means the website is not competing with Forgejo. It is presenting Forgejo content in a way that makes sense for learners. + +## Why Courses Are Repositories + +Courses need more than one page of text. A robotics lesson might include diagrams, code samples, CAD files, firmware, or wiring references. Keeping a lesson in a repository makes those materials easy to version, review, and download. + +The current course convention is: + +```text +lessons/ + 01-getting-started/ + 01-blink-an-led/ + index.md + example-code/ + wiring.png +``` + +Each lesson is just a folder with a Markdown file and any supporting assets it needs. Adding a lesson is a normal Git change. Editing a lesson is a normal Git change. Creating a new course is creating a new public repository with a `lessons/` folder. + +## Why Discussions Are Issues + +Forum posts and blockers should not disappear into a custom database if the website changes. Backing discussions with Forgejo issues gives the community a durable model that already supports authorship, replies, labels, links, and permissions. + +The site reads public issues for general discussions and shows replies under each discussion. When a signed-in user replies from the website, the backend uses that user's Forgejo session token so the comment belongs to the real person, not a bot account. + +## Anonymous Reading, Signed-In Writing + +A key design rule is that reading should be easy. Public learning content should be visible without an account. + +Writing is different. Replies and future content actions should require a real Forgejo identity. This keeps the contribution model clear: + +- Public visitors can read. +- Members sign in with Forgejo to participate. +- Forgejo remains the place that enforces repository and issue permissions. + +## What Is Next + +This prototype is enough to prove the shape of the system, but there is plenty left to improve: + +- Render individual blog posts as first-class pages. +- Add user profiles and progress tracking. +- Improve course navigation for longer curricula. +- Add search across courses, posts, and discussions. +- Use Forgejo webhooks and server-sent events so site activity updates quickly. +- Add better issue-to-lesson linking so discussions can appear directly under the related lesson. + +The important part is that the foundation is simple. Robot U can grow as a community learning site without hiding the tools and workflows that make technical collaboration work. diff --git a/calendar_feeds.py b/calendar_feeds.py index 683e56a..5466cae 100644 --- a/calendar_feeds.py +++ b/calendar_feeds.py @@ -1,11 +1,15 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import UTC, date, datetime, time +from datetime import UTC, datetime, time, timedelta +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from urllib.parse import urlparse import httpx +MAX_RECURRENCE_OCCURRENCES = 120 +RECURRENCE_LOOKAHEAD_DAYS = 365 + class CalendarFeedError(RuntimeError): pass @@ -29,7 +33,10 @@ class CalendarFeed: async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed: normalized_url = _normalize_calendar_url(url) async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: - response = await client.get(normalized_url) + response = await client.get( + normalized_url, + headers={"User-Agent": "RobotUCalendar/0.1 (+https://aksal.cloud)"}, + ) if not response.is_success: raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}") @@ -55,6 +62,7 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]: lines = _unfold_ics_lines(raw_text) calendar_name = _calendar_name(lines, feed_url) now = datetime.now(UTC) + recurrence_until = now + timedelta(days=RECURRENCE_LOOKAHEAD_DAYS) events: list[CalendarEvent] = [] current_event: dict[str, str] | None = None @@ -63,9 +71,14 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]: current_event = {} continue if line == "END:VEVENT": - parsed_event = _event_from_properties(current_event or {}, calendar_name) - if parsed_event and parsed_event.starts_at >= now: - events.append(parsed_event) + events.extend( + _events_from_properties( + current_event or {}, + calendar_name, + now=now, + recurrence_until=recurrence_until, + ), + ) current_event = None continue if current_event is None or ":" not in line: @@ -105,23 +118,139 @@ def _calendar_host(feed_url: str) -> str: return parsed.hostname or "Calendar" -def _event_from_properties(properties: dict[str, str], calendar_name: str) -> CalendarEvent | None: +def _events_from_properties( + properties: dict[str, str], + calendar_name: str, + *, + now: datetime, + recurrence_until: datetime, +) -> list[CalendarEvent]: title = _decode_ics_text(properties.get("SUMMARY", "").strip()) start_key = next((key for key in properties if key.startswith("DTSTART")), None) if not title or not start_key: - return None + return [] starts_at = _parse_ics_datetime(start_key, properties[start_key]) if starts_at is None: - return None + return [] location = _decode_ics_text(properties.get("LOCATION", "").strip()) - return CalendarEvent( - title=title, - starts_at=starts_at, - source=calendar_name, - mode=location or "Calendar", + event_template = { + "title": title, + "source": calendar_name, + "mode": location or "Calendar", + } + recurrence_rule = properties.get("RRULE") + if not recurrence_rule: + if starts_at >= now: + return [CalendarEvent(starts_at=starts_at, **event_template)] + return [] + + return [ + CalendarEvent(starts_at=occurrence, **event_template) + for occurrence in _recurrence_occurrences( + starts_at, + recurrence_rule, + now=now, + recurrence_until=recurrence_until, + start_key=start_key, + ) + ] + + +def _recurrence_occurrences( + starts_at: datetime, + raw_rule: str, + *, + now: datetime, + recurrence_until: datetime, + start_key: str, +) -> list[datetime]: + rule = _parse_recurrence_rule(raw_rule) + frequency = rule.get("FREQ", "").upper() + interval = _positive_int(rule.get("INTERVAL"), default=1) + count = _positive_int(rule.get("COUNT"), default=MAX_RECURRENCE_OCCURRENCES) + until = _parse_recurrence_until(rule.get("UNTIL"), start_key) + effective_until = min( + [candidate for candidate in (until, recurrence_until) if candidate is not None], + default=recurrence_until, ) + occurrences: list[datetime] = [] + occurrence = starts_at + + for index in range(count): + if occurrence > effective_until: + break + if occurrence >= now: + occurrences.append(occurrence) + if len(occurrences) >= MAX_RECURRENCE_OCCURRENCES: + break + + next_occurrence = _next_recurrence_occurrence(occurrence, frequency, interval) + if next_occurrence is None or next_occurrence <= occurrence: + break + occurrence = next_occurrence + + return occurrences + + +def _parse_recurrence_rule(raw_rule: str) -> dict[str, str]: + rule: dict[str, str] = {} + for segment in raw_rule.split(";"): + if "=" not in segment: + continue + key, value = segment.split("=", 1) + rule[key.strip().upper()] = value.strip() + return rule + + +def _positive_int(value: str | None, *, default: int) -> int: + if value is None: + return default + try: + parsed = int(value) + except ValueError: + return default + return parsed if parsed > 0 else default + + +def _parse_recurrence_until(value: str | None, start_key: str) -> datetime | None: + if not value: + return None + return _parse_ics_datetime(start_key, value) + + +def _next_recurrence_occurrence( + occurrence: datetime, + frequency: str, + interval: int, +) -> datetime | None: + if frequency == "DAILY": + return occurrence + timedelta(days=interval) + if frequency == "WEEKLY": + return occurrence + timedelta(weeks=interval) + if frequency == "MONTHLY": + return _add_months(occurrence, interval) + if frequency == "YEARLY": + return _add_months(occurrence, interval * 12) + return None + + +def _add_months(value: datetime, months: int) -> datetime: + month_index = value.month - 1 + months + year = value.year + month_index // 12 + month = month_index % 12 + 1 + day = min(value.day, _days_in_month(year, month)) + return value.replace(year=year, month=month, day=day) + + +def _days_in_month(year: int, month: int) -> int: + if month == 12: + next_month = datetime(year + 1, 1, 1) + else: + next_month = datetime(year, month + 1, 1) + this_month = datetime(year, month, 1) + return (next_month - this_month).days def _parse_ics_datetime(key: str, value: str) -> datetime | None: @@ -132,13 +261,31 @@ def _parse_ics_datetime(key: str, value: str) -> datetime | None: if value.endswith("Z"): return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC) if "T" in value: - return datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=UTC) + return _with_ics_timezone(datetime.strptime(value, "%Y%m%dT%H%M%S"), key) parsed_date = datetime.strptime(value, "%Y%m%d").date() return datetime.combine(parsed_date, time.min, tzinfo=UTC) except ValueError: return None +def _with_ics_timezone(value: datetime, key: str) -> datetime: + timezone_name = _ics_timezone_name(key) + if not timezone_name: + return value.replace(tzinfo=UTC) + + try: + return value.replace(tzinfo=ZoneInfo(timezone_name)) + except ZoneInfoNotFoundError: + return value.replace(tzinfo=UTC) + + +def _ics_timezone_name(key: str) -> str | None: + for segment in key.split(";")[1:]: + if segment.startswith("TZID="): + return segment.split("=", 1)[1].strip() or None + return None + + def _decode_ics_text(value: str) -> str: return ( value.replace("\\n", "\n") diff --git a/forgejo_client.py b/forgejo_client.py index 1aec797..5482341 100644 --- a/forgejo_client.py +++ b/forgejo_client.py @@ -14,8 +14,9 @@ class ForgejoClientError(RuntimeError): class ForgejoClient: - def __init__(self, settings: Settings) -> None: + def __init__(self, settings: Settings, forgejo_token: str | None = None) -> None: self._settings = settings + self._forgejo_token = forgejo_token or settings.forgejo_token self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds) async def __aenter__(self) -> ForgejoClient: @@ -30,6 +31,54 @@ class ForgejoClient: absolute_url=True, ) + async def exchange_oauth_code( + self, + *, + token_endpoint: str, + client_id: str, + client_secret: str, + code: str, + redirect_uri: str, + code_verifier: str, + ) -> dict[str, Any]: + response = await self._client.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + headers={"Accept": "application/json"}, + ) + if response.is_success: + payload = response.json() + if isinstance(payload, dict): + return payload + raise ForgejoClientError("Unexpected Forgejo OAuth token payload.") + + message = self._extract_error_message(response) + raise ForgejoClientError(f"{response.status_code} from Forgejo OAuth: {message}") + + async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, Any]: + response = await self._client.get( + userinfo_endpoint, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {access_token}", + }, + ) + if response.is_success: + payload = response.json() + if isinstance(payload, dict): + return payload + raise ForgejoClientError("Unexpected Forgejo UserInfo payload.") + + message = self._extract_error_message(response) + raise ForgejoClientError(f"{response.status_code} from Forgejo UserInfo: {message}") + async def fetch_current_user(self) -> dict[str, Any]: return await self._get_json("/api/v1/user", auth_required=True) @@ -39,22 +88,37 @@ class ForgejoClient: params={ "limit": self._settings.forgejo_repo_scan_limit, "page": 1, + "private": "false", + "is_private": "false", }, - auth_required=True, ) data = payload.get("data", []) return [repo for repo in data if isinstance(repo, dict)] - async def search_recent_issues(self) -> list[dict[str, Any]]: + async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]: payload = await self._get_json( - "/api/v1/repos/issues/search", + f"/api/v1/repos/{owner}/{repo}", + ) + if isinstance(payload, dict): + return payload + raise ForgejoClientError(f"Unexpected repository payload for {owner}/{repo}") + + async def list_repo_issues( + self, + owner: str, + repo: str, + *, + limit: int, + ) -> list[dict[str, Any]]: + payload = await self._get_json( + f"/api/v1/repos/{owner}/{repo}/issues", params={ "state": "open", "page": 1, - "limit": self._settings.forgejo_recent_issue_limit, + "limit": limit, "type": "issues", + "sort": "recentupdate", }, - auth_required=True, ) if isinstance(payload, list): return [issue for issue in payload if isinstance(issue, dict)] @@ -65,7 +129,7 @@ class ForgejoClient: if path: endpoint = f"{endpoint}/{path.strip('/')}" - payload = await self._get_json(endpoint, auth_required=True) + payload = await self._get_json(endpoint) if isinstance(payload, list): return [entry for entry in payload if isinstance(entry, dict)] if isinstance(payload, dict): @@ -80,16 +144,31 @@ class ForgejoClient: ) -> list[dict[str, Any]]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", - auth_required=True, ) if isinstance(payload, list): return [comment for comment in payload if isinstance(comment, dict)] return [] + async def create_issue_comment( + self, + owner: str, + repo: str, + issue_number: int, + body: str, + ) -> dict[str, Any]: + payload = await self._request_json( + "POST", + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", + json_payload={"body": body}, + auth_required=True, + ) + if isinstance(payload, dict): + return payload + raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}") + async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}", - auth_required=True, ) if not isinstance(payload, dict): raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}") @@ -118,17 +197,41 @@ class ForgejoClient: params: Mapping[str, str | int] | None = None, auth_required: bool = False, ) -> dict[str, Any] | list[Any]: - if auth_required and not self._settings.forgejo_token: + return await self._request_json( + "GET", + path, + absolute_url=absolute_url, + params=params, + auth_required=auth_required, + ) + + async def _request_json( + self, + method: str, + path: str, + *, + absolute_url: bool = False, + params: Mapping[str, str | int] | None = None, + json_payload: Mapping[str, object] | None = None, + auth_required: bool = False, + ) -> dict[str, Any] | list[Any]: + if auth_required and not self._forgejo_token: raise ForgejoClientError( "This Forgejo instance requires an authenticated API token for repo and issue access.", ) url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}" headers = {} - if self._settings.forgejo_token: - headers["Authorization"] = f"token {self._settings.forgejo_token}" + if self._forgejo_token: + headers["Authorization"] = f"token {self._forgejo_token}" - response = await self._client.get(url, params=params, headers=headers) + response = await self._client.request( + method, + url, + params=params, + headers=headers, + json=json_payload, + ) if response.is_success: return response.json() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ee5cea..1a9494b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "preact/hooks"; import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent"; import type { + AuthState, CourseCard, CourseChapter, CourseLesson, @@ -48,6 +49,22 @@ function parseDiscussionRoute(pathname: string): number | null { return Number.isFinite(issueId) ? issueId : null; } +function isSignInRoute(pathname: string): boolean { + return normalizePathname(pathname) === "/signin"; +} + +function isCoursesIndexRoute(pathname: string): boolean { + return normalizePathname(pathname) === "/courses"; +} + +function isDiscussionsIndexRoute(pathname: string): boolean { + return normalizePathname(pathname) === "/discussions"; +} + +function isActivityRoute(pathname: string): boolean { + return normalizePathname(pathname) === "/activity"; +} + function parseCourseRoute(pathname: string): { owner: string; repo: string } | null { const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/); if (!match) { @@ -143,12 +160,17 @@ function usePathname() { }, []); function navigate(nextPath: string) { - const normalized = normalizePathname(nextPath); - if (normalized === pathname) { + const nextUrl = new URL(nextPath, window.location.origin); + const normalized = normalizePathname(nextUrl.pathname); + const renderedPath = `${normalized}${nextUrl.search}`; + if ( + normalized === pathname && + renderedPath === `${window.location.pathname}${window.location.search}` + ) { return; } - window.history.pushState({}, "", normalized); + window.history.pushState({}, "", renderedPath); window.scrollTo({ top: 0, behavior: "auto" }); setPathname(normalized); } @@ -168,6 +190,152 @@ function EmptyState(props: { copy: string }) { return

{props.copy}

; } +function canUseInteractiveAuth(auth: AuthState): boolean { + return auth.authenticated && auth.can_reply; +} + +function forgejoSignInUrl(returnTo: string): string { + const target = new URL("/api/auth/forgejo/start", window.location.origin); + target.searchParams.set("return_to", returnTo); + return `${target.pathname}${target.search}`; +} + +function TopBar(props: { + auth: AuthState; + pathname: string; + onGoHome: () => void; + onGoCourses: () => void; + onGoDiscussions: () => void; + onGoActivity: () => void; + onGoSignIn: () => void; + onSignOut: () => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { + auth, + pathname, + onGoHome, + onGoCourses, + onGoDiscussions, + onGoActivity, + onGoSignIn, + onSignOut, + } = props; + + function navClass(targetPath: string) { + const normalized = normalizePathname(pathname); + const isActive = + targetPath === "/" + ? normalized === "/" + : normalized === targetPath || normalized.startsWith(`${targetPath}/`); + return isActive ? "topbar-link active" : "topbar-link"; + } + + const normalizedPathname = normalizePathname(pathname); + const brandClass = normalizedPathname === "/" ? "topbar-brand active" : "topbar-brand"; + const menuClass = isMenuOpen ? "topbar-menu open" : "topbar-menu"; + + useEffect(() => { + setIsMenuOpen(false); + }, [pathname]); + + function handleNavigation(callback: () => void) { + setIsMenuOpen(false); + callback(); + } + + return ( +
+ + + + +
+ + +
+ {auth.authenticated ? ( +

{auth.login}

+ ) : ( +

Not signed in

+ )} + {auth.authenticated ? ( + + ) : ( + + )} +
+
+
+ ); +} + function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) { const { course, onOpenCourse } = props; @@ -247,20 +415,123 @@ function DiscussionReplyCard(props: { reply: DiscussionReply }) { ); } -function ComposeBox() { +function repoParts(fullName: string): { owner: string; repo: string } | null { + const [owner, repo] = fullName.split("/", 2); + if (!owner || !repo) { + return null; + } + + return { owner, repo }; +} + +async function responseError(response: Response, fallback: string): Promise { + const payload = (await response.json().catch(() => null)) as { detail?: string } | null; + return new Error(payload?.detail || fallback); +} + +async function postDiscussionReply( + discussion: DiscussionCard, + body: string, +): Promise { + const repo = repoParts(discussion.repo); + if (!repo) { + throw new Error("This discussion is missing repository information."); + } + + const response = await fetch("/api/discussions/replies", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + owner: repo.owner, + repo: repo.repo, + number: discussion.number, + body, + }), + }); + + if (!response.ok) { + throw await responseError(response, `Reply failed with ${response.status}`); + } + + return (await response.json()) as DiscussionReply; +} + +function ComposeBox(props: { + discussion: DiscussionCard; + onReplyCreated: (discussionId: number, reply: DiscussionReply) => void; + auth: AuthState; + onGoSignIn: () => void; +}) { + const { discussion, onReplyCreated, auth, onGoSignIn } = props; + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const trimmedBody = body.trim(); + const canReply = canUseInteractiveAuth(auth); + + async function submitReply(event: SubmitEvent) { + event.preventDefault(); + if (!canReply) { + setError("Sign in before replying."); + return; + } + + if (!trimmedBody || isSubmitting) { + return; + } + + setError(null); + setIsSubmitting(true); + try { + const reply = await postDiscussionReply(discussion, trimmedBody); + onReplyCreated(discussion.id, reply); + setBody(""); + } catch (replyError) { + const message = + replyError instanceof Error ? replyError.message : "Reply could not be posted."; + setError(message); + } finally { + setIsSubmitting(false); + } + } + return ( -
{ - event.preventDefault(); - }} - > -