From d8fccef17d56e8468a5597e3c9133b02c9b25fda Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 21:55:02 -0400 Subject: [PATCH 01/11] feat(server): add a self-hostable projects API and collaboration relay Sharing, accounts, and live collaboration could only ever run on share.geolibre.app and Cloudflare, so a self-hoster running the Docker image got the map and the sidecar but none of it. Write the projects and identity contract down, add a FastAPI reference implementation of it, lift the collab session logic out of the Durable Object into a shared core, and host that core from a plain Node relay as well. One conformance suite runs against both relays so their permission behavior cannot drift. Closes #1685 Closes #1686 --- .dockerignore | 4 +- .github/workflows/ci.yml | 11 +- .gitignore | 5 + Dockerfile | 1 + backend/geolibre_server_api/.dockerignore | 5 + backend/geolibre_server_api/.gitignore | 2 + backend/geolibre_server_api/Dockerfile | 14 + backend/geolibre_server_api/README.md | 23 + .../geolibre_server_api/__init__.py | 5 + .../geolibre_server_api/main.py | 718 ++++++++++++++++++ backend/geolibre_server_api/pyproject.toml | 25 + backend/geolibre_server_api/tests/test_api.py | 162 ++++ docker-compose.yml | 100 +++ docs/collaboration.md | 26 +- docs/getting-started.md | 38 +- docs/server-api.md | 242 ++++++ package-lock.json | 603 ++++++++++++++- package.json | 2 +- packages/collab-core/package.json | 17 + packages/collab-core/src/comment-validate.ts | 203 +++++ packages/collab-core/src/index.ts | 3 + packages/collab-core/src/protocol.ts | 183 +++++ packages/collab-core/src/session.ts | 153 ++++ packages/collab-core/tsconfig.json | 12 + tests/collab-core-conformance.test.ts | 103 +++ workers/collab-node/Dockerfile | 19 + workers/collab-node/README.md | 24 + workers/collab-node/package.json | 20 + workers/collab-node/src/server.ts | 517 +++++++++++++ workers/collab-node/src/store.ts | 122 +++ workers/collab-node/test/relay.test.ts | 175 +++++ workers/collab-node/tsconfig.json | 14 + workers/collab/package.json | 3 + workers/collab/src/comment-validate.ts | 204 +---- workers/collab/src/protocol.ts | 184 +---- workers/collab/src/session.ts | 179 ++--- 36 files changed, 3574 insertions(+), 547 deletions(-) create mode 100644 backend/geolibre_server_api/.dockerignore create mode 100644 backend/geolibre_server_api/.gitignore create mode 100644 backend/geolibre_server_api/Dockerfile create mode 100644 backend/geolibre_server_api/README.md create mode 100644 backend/geolibre_server_api/geolibre_server_api/__init__.py create mode 100644 backend/geolibre_server_api/geolibre_server_api/main.py create mode 100644 backend/geolibre_server_api/pyproject.toml create mode 100644 backend/geolibre_server_api/tests/test_api.py create mode 100644 docker-compose.yml create mode 100644 docs/server-api.md create mode 100644 packages/collab-core/package.json create mode 100644 packages/collab-core/src/comment-validate.ts create mode 100644 packages/collab-core/src/index.ts create mode 100644 packages/collab-core/src/protocol.ts create mode 100644 packages/collab-core/src/session.ts create mode 100644 packages/collab-core/tsconfig.json create mode 100644 tests/collab-core-conformance.test.ts create mode 100644 workers/collab-node/Dockerfile create mode 100644 workers/collab-node/README.md create mode 100644 workers/collab-node/package.json create mode 100644 workers/collab-node/src/server.ts create mode 100644 workers/collab-node/src/store.ts create mode 100644 workers/collab-node/test/relay.test.ts create mode 100644 workers/collab-node/tsconfig.json diff --git a/.dockerignore b/.dockerignore index 0fb9fa5068..37128eb74d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,9 @@ apps/geolibre-desktop/dist apps/geolibre-desktop/src-tauri backend/geolibre_server/.venv backend/**/__pycache__ -workers +workers/* +!workers/collab-node +!workers/collab-node/** docs sample-data coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afcfe653ce..e5adba4145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,9 @@ jobs: with: python-version: "3.12" cache: pip - cache-dependency-path: backend/geolibre_server/pyproject.toml + cache-dependency-path: | + backend/geolibre_server/pyproject.toml + backend/geolibre_server_api/pyproject.toml - name: Install Rust stable uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @@ -176,7 +178,9 @@ jobs: - name: Install backend test dependencies # The full test suite needs the optional engines; without them the # vector/raster/SQL/ML tests skip themselves and CI is green but hollow. - run: python -m pip install -e "backend/geolibre_server[test]" + run: | + python -m pip install -e "backend/geolibre_server[test]" + python -m pip install -e "backend/geolibre_server_api[test]" # backend/geolibre_server/uv.lock is committed because the desktop # installers bundle that project and `uv run --frozen` it from a read-only @@ -217,3 +221,6 @@ jobs: run: npm run ci env: VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }} + + - name: Test projects and identity server + run: python -m pytest backend/geolibre_server_api/tests diff --git a/.gitignore b/.gitignore index 9d49092e8c..618cc24bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,8 @@ python/examples/my-map.geolibre.json # source of truth is Entitlements.mas.plist.template) apps/geolibre-desktop/src-tauri/mas/Entitlements.mas.plist apps/geolibre-desktop/src-tauri/mas/embedded.provisionprofile + +# Local SQLite state for the reference projects/identity server. GEOLIBRE_DATABASE_URL +# defaults to sqlite:///./geolibre-server-api.db, so running it from the repo root +# (the obvious place) drops the file here. +/geolibre-server-api.db diff --git a/Dockerfile b/Dockerfile index a469a17653..03746cb1bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ WORKDIR /app COPY package.json package-lock.json ./ COPY apps/geolibre-desktop/package.json apps/geolibre-desktop/package.json COPY packages/core/package.json packages/core/package.json +COPY packages/collab-core/package.json packages/collab-core/package.json COPY packages/map/package.json packages/map/package.json COPY packages/plugins/package.json packages/plugins/package.json COPY packages/processing/package.json packages/processing/package.json diff --git a/backend/geolibre_server_api/.dockerignore b/backend/geolibre_server_api/.dockerignore new file mode 100644 index 0000000000..45dc4d1def --- /dev/null +++ b/backend/geolibre_server_api/.dockerignore @@ -0,0 +1,5 @@ +.venv/ +.pytest_cache/ +__pycache__/ +tests/ +*.db diff --git a/backend/geolibre_server_api/.gitignore b/backend/geolibre_server_api/.gitignore new file mode 100644 index 0000000000..f830376bf1 --- /dev/null +++ b/backend/geolibre_server_api/.gitignore @@ -0,0 +1,2 @@ +geolibre-server-api.db +data/ diff --git a/backend/geolibre_server_api/Dockerfile b/backend/geolibre_server_api/Dockerfile new file mode 100644 index 0000000000..14c27c49c7 --- /dev/null +++ b/backend/geolibre_server_api/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-slim + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY geolibre_server_api ./geolibre_server_api +RUN pip install --no-cache-dir ".[postgres,s3]" + +RUN useradd --create-home geolibre && mkdir -p /data && chown geolibre:geolibre /data +USER geolibre + +ENV GEOLIBRE_DATABASE_URL=sqlite:////data/geolibre-server-api.db \ + GEOLIBRE_STORAGE_PATH=/data/objects +EXPOSE 8000 +CMD ["geolibre-server-api"] diff --git a/backend/geolibre_server_api/README.md b/backend/geolibre_server_api/README.md new file mode 100644 index 0000000000..c123de4bcb --- /dev/null +++ b/backend/geolibre_server_api/README.md @@ -0,0 +1,23 @@ +# GeoLibre server API + +Reference implementation of [`docs/server-api.md`](../../docs/server-api.md). +It is a separate multi-user service from the local desktop processing sidecar. + +```bash +pip install -e ".[test]" +geolibre-server-api +``` + +Configuration: + +- `GEOLIBRE_DATABASE_URL`: SQLAlchemy URL; defaults to + `sqlite:///./geolibre-server-api.db`. Use + `postgresql+psycopg://user:password@host/database` with the `postgres` extra. +- `GEOLIBRE_STORAGE_PATH`: local object directory, default `./data`. +- `GEOLIBRE_STORAGE=s3`, `GEOLIBRE_S3_BUCKET`, and optional + `GEOLIBRE_S3_ENDPOINT` / `GEOLIBRE_S3_REGION`: S3-compatible storage (install + the `s3` extra; standard AWS credential environment variables apply). +- `GEOLIBRE_PUBLIC_URL`: externally reachable API origin. +- `GEOLIBRE_VIEWER_URL`: GeoLibre viewer origin. +- `GEOLIBRE_CORS_ORIGINS`: comma-separated web origins, default `*`. +- `GEOLIBRE_MAX_PROJECT_BYTES`, `GEOLIBRE_MAX_THUMBNAIL_BYTES`: upload limits. diff --git a/backend/geolibre_server_api/geolibre_server_api/__init__.py b/backend/geolibre_server_api/geolibre_server_api/__init__.py new file mode 100644 index 0000000000..4390bf8993 --- /dev/null +++ b/backend/geolibre_server_api/geolibre_server_api/__init__.py @@ -0,0 +1,5 @@ +"""GeoLibre projects and identity reference server.""" + +from .main import create_app + +__all__ = ["create_app"] diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py new file mode 100644 index 0000000000..e007e192bd --- /dev/null +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -0,0 +1,718 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import secrets +import shutil +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Annotated, Literal +from urllib.parse import quote + +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, RedirectResponse +from pydantic import BaseModel, Field +from sqlalchemy import ( + Boolean, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + create_engine, + delete, + func, + select, +) +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + Session, + mapped_column, + relationship, + sessionmaker, +) + +Visibility = Literal["public", "unlisted", "private"] +USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,37}[a-z0-9])?$") +SLUG_RE = re.compile(r"[^a-z0-9]+") +IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"} + + +class Base(DeclarativeBase): + pass + + +class Account(Base): + __tablename__ = "accounts" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + username: Mapped[str | None] = mapped_column(String(39), unique=True, nullable=True) + password_hash: Mapped[str] = mapped_column(Text) + created_at: Mapped[str] = mapped_column(String(32)) + projects: Mapped[list[Project]] = relationship( + back_populates="owner", cascade="all, delete-orphan" + ) + + +class Token(Base): + __tablename__ = "tokens" + digest: Mapped[str] = mapped_column(String(64), primary_key=True) + account_id: Mapped[str] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), index=True + ) + created_at: Mapped[str] = mapped_column(String(32)) + + +class Project(Base): + __tablename__ = "projects" + __table_args__ = (UniqueConstraint("owner_id", "slug", name="uq_project_owner_slug"),) + id: Mapped[str] = mapped_column(String(36), primary_key=True) + owner_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), index=True) + slug: Mapped[str] = mapped_column(String(100)) + title: Mapped[str] = mapped_column(String(100)) + description: Mapped[str] = mapped_column(Text, default="") + visibility: Mapped[str] = mapped_column(String(10)) + tags_json: Mapped[str] = mapped_column(Text, default="[]") + thumbnail_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + views: Mapped[int] = mapped_column(Integer, default=0) + fork_count: Mapped[int] = mapped_column(Integer, default=0) + featured: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(String(32)) + updated_at: Mapped[str] = mapped_column(String(32), index=True) + owner: Mapped[Account] = relationship(back_populates="projects") + versions: Mapped[list[Version]] = relationship( + back_populates="project", cascade="all, delete-orphan", order_by="Version.number" + ) + + +class Version(Base): + __tablename__ = "versions" + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True + ) + number: Mapped[int] = mapped_column(Integer, primary_key=True) + object_key: Mapped[str] = mapped_column(Text) + created_at: Mapped[str] = mapped_column(String(32)) + project: Mapped[Project] = relationship(back_populates="versions") + + +class Credentials(BaseModel): + username: str + password: str + + +class ProjectCreate(BaseModel): + filename: str = Field(max_length=255) + content: str + visibility: Visibility + + +class ProjectPatch(BaseModel): + title: str | None = Field(default=None, max_length=100) + description: str | None = Field(default=None, max_length=2000) + visibility: Visibility | None = None + tags: list[str] | None = None + + +class ContentUpdate(BaseModel): + content: str + + +class ForkRequest(BaseModel): + visibility: Visibility = "private" + + +def now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def password_hash(password: str, salt: bytes | None = None) -> str: + if not password: + raise ValueError("password is required") + salt = salt or secrets.token_bytes(16) + digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1) + return f"scrypt${salt.hex()}${digest.hex()}" + + +def password_matches(password: str, encoded: str) -> bool: + try: + _, salt, expected = encoded.split("$") + return hmac.compare_digest( + password_hash(password, bytes.fromhex(salt)).split("$")[2], expected + ) + except (ValueError, TypeError): + return False + + +def token_digest(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +class FileStorage: + def __init__(self, root: str): + self.root = Path(root).resolve() + self.root.mkdir(parents=True, exist_ok=True) + + def put(self, key: str, data: bytes, content_type: str) -> None: + path = self.root / key + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def get(self, key: str) -> bytes: + try: + return (self.root / key).read_bytes() + except FileNotFoundError as exc: + raise KeyError(key) from exc + + def delete(self, key: str) -> None: + (self.root / key).unlink(missing_ok=True) + + def delete_project(self, project_id: str) -> None: + shutil.rmtree(self.root / "projects" / project_id, ignore_errors=True) + + +class S3Storage: + def __init__(self, bucket: str, endpoint: str | None, region: str | None): + try: + import boto3 + except ImportError as exc: + raise RuntimeError("S3 storage requires the 's3' optional dependency") from exc + self.bucket = bucket + self.client = boto3.client("s3", endpoint_url=endpoint, region_name=region) + + def put(self, key: str, data: bytes, content_type: str) -> None: + self.client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type) + + def get(self, key: str) -> bytes: + try: + return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read() + except self.client.exceptions.NoSuchKey as exc: + raise KeyError(key) from exc + + def delete(self, key: str) -> None: + self.client.delete_object(Bucket=self.bucket, Key=key) + + def delete_project(self, project_id: str) -> None: + prefix = f"projects/{project_id}/" + paginator = self.client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix): + objects = [{"Key": item["Key"]} for item in page.get("Contents", [])] + if objects: + self.client.delete_objects(Bucket=self.bucket, Delete={"Objects": objects}) + + +def make_storage(): + if os.getenv("GEOLIBRE_STORAGE", "filesystem").lower() == "s3": + bucket = os.getenv("GEOLIBRE_S3_BUCKET") + if not bucket: + raise RuntimeError("GEOLIBRE_S3_BUCKET is required for S3 storage") + return S3Storage(bucket, os.getenv("GEOLIBRE_S3_ENDPOINT"), os.getenv("GEOLIBRE_S3_REGION")) + return FileStorage(os.getenv("GEOLIBRE_STORAGE_PATH", "./data")) + + +def parse_content(content: str, max_bytes: int) -> dict: + if len(content.encode()) > max_bytes: + raise HTTPException(413, f"project document exceeds the {max_bytes} byte limit") + try: + value = json.loads(content) + except json.JSONDecodeError as exc: + raise HTTPException(422, f"content must be valid JSON: {exc.msg}") from exc + if not isinstance(value, dict): + raise HTTPException(422, "content must contain a JSON object") + return value + + +def slugify(value: str) -> str: + slug = SLUG_RE.sub("-", value.lower()).strip("-")[:100].rstrip("-") + return slug or "project" + + +def title_from(document: dict, filename: str) -> str: + candidate = document.get("title") + if not isinstance(candidate, str) or not candidate.strip(): + candidate = Path(filename).name.removesuffix(".geolibre.json").removesuffix(".json") + candidate = candidate.strip() + if len(candidate) > 100: + raise HTTPException(422, "project title must not exceed 100 characters") + return candidate or "Untitled" + + +def create_app( + database_url: str | None = None, + storage=None, + public_url: str | None = None, +) -> FastAPI: + database_url = database_url or os.getenv( + "GEOLIBRE_DATABASE_URL", "sqlite:///./geolibre-server-api.db" + ) + connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} + engine = create_engine(database_url, connect_args=connect_args) + Base.metadata.create_all(engine) + sessions = sessionmaker(engine, expire_on_commit=False) + object_storage = storage or make_storage() + base_url = (public_url or os.getenv("GEOLIBRE_PUBLIC_URL", "http://localhost:8000")).rstrip("/") + viewer_url = os.getenv("GEOLIBRE_VIEWER_URL", "https://app.geolibre.org/").rstrip("/") + "/" + max_project_bytes = int(os.getenv("GEOLIBRE_MAX_PROJECT_BYTES", str(50 * 1024 * 1024))) + max_thumbnail_bytes = int(os.getenv("GEOLIBRE_MAX_THUMBNAIL_BYTES", str(5 * 1024 * 1024))) + + app = FastAPI(title="GeoLibre projects and identity API", version="1.0") + app.state.engine = engine + app.state.storage = object_storage + origins = [x.strip() for x in os.getenv("GEOLIBRE_CORS_ORIGINS", "*").split(",")] + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=origins != ["*"], + allow_methods=["*"], + allow_headers=["Authorization", "Content-Type"], + ) + + @app.get("/health") + def health(): + return {"ok": True, "service": "geolibre-server"} + + @app.exception_handler(HTTPException) + async def http_error(_request: Request, exc: HTTPException): + detail = exc.detail if isinstance(exc.detail, str) else "request failed" + return JSONResponse({"error": detail}, status_code=exc.status_code, headers=exc.headers) + + @app.exception_handler(RequestValidationError) + async def validation_error(_request: Request, exc: RequestValidationError): + return JSONResponse({"error": str(exc.errors()[0]["msg"])}, status_code=422) + + def db(): + with sessions() as session: + yield session + + def optional_account( + authorization: Annotated[str | None, Header()] = None, + session: Session = Depends(db), + ) -> Account | None: + if not authorization: + return None + if not authorization.startswith("Bearer "): + raise HTTPException(401, "invalid authorization") + row = session.get(Token, token_digest(authorization[7:])) + if row is None: + raise HTTPException(401, "invalid or expired token") + return session.get(Account, row.account_id) + + def required_account(account: Account | None = Depends(optional_account)) -> Account: + if account is None: + raise HTTPException(401, "authentication required") + return account + + def account_json(account: Account) -> dict: + return {"id": account.id, "username": account.username, "createdAt": account.created_at} + + def issue_token(session: Session, account: Account) -> str: + value = secrets.token_urlsafe(32) + session.add(Token(digest=token_digest(value), account_id=account.id, created_at=now())) + session.commit() + return value + + def unique_slug(session: Session, owner_id: str, desired: str) -> str: + base = slugify(desired) + candidate = base + suffix = 2 + while session.scalar( + select(Project.id).where(Project.owner_id == owner_id, Project.slug == candidate) + ): + tail = f"-{suffix}" + candidate = base[: 100 - len(tail)].rstrip("-") + tail + suffix += 1 + return candidate + + def project_json(project: Project) -> dict: + username = project.owner.username or "" + raw = f"{base_url}/{quote(username)}/{quote(project.slug)}.geolibre.json" + page = f"{base_url}/{quote(username)}/{quote(project.slug)}" + return { + "id": project.id, + "username": username, + "slug": project.slug, + "title": project.title, + "description": project.description, + "visibility": project.visibility, + "thumbnailUrl": f"/api/projects/{project.id}/thumbnail" + if project.thumbnail_type + else None, + "views": project.views, + "forkCount": project.fork_count, + "versionCount": len(project.versions), + "featured": project.featured, + "createdAt": project.created_at, + "updatedAt": project.updated_at, + "tags": json.loads(project.tags_json), + "rawJsonUrl": raw, + "projectUrl": page, + "viewerUrl": viewer_url + "?project=" + quote(raw, safe=""), + } + + def visible(project: Project | None, account: Account | None) -> Project: + if project is None or ( + project.visibility == "private" and (account is None or project.owner_id != account.id) + ): + raise HTTPException(404, "project not found") + return project + + def owned(project: Project | None, account: Account) -> Project: + if project is None: + raise HTTPException(404, "project not found") + if project.owner_id != account.id: + raise HTTPException(403, "project ownership required") + return project + + def create_project( + session: Session, + account: Account, + content: str, + filename: str, + visibility: Visibility, + *, + commit: bool = True, + ) -> Project: + if not account.username: + raise HTTPException(400, "username required") + document = parse_content(content, max_project_bytes) + title = title_from(document, filename) + timestamp = now() + project = Project( + id=str(uuid.uuid4()), + owner_id=account.id, + slug=unique_slug(session, account.id, title or filename), + title=title, + description="", + visibility=visibility, + tags_json="[]", + created_at=timestamp, + updated_at=timestamp, + ) + session.add(project) + session.flush() + key = f"projects/{project.id}/versions/1.json" + object_storage.put(key, content.encode(), "application/json") + session.add(Version(project_id=project.id, number=1, object_key=key, created_at=timestamp)) + if commit: + session.commit() + session.refresh(project) + return project + + @app.post("/api/accounts", status_code=201) + def create_account(body: Credentials, session: Session = Depends(db)): + username = body.username.strip() + if not USERNAME_RE.fullmatch(username): + raise HTTPException(422, "username must be 3-39 lowercase letters, digits, or hyphens") + if len(body.password) < 8: + raise HTTPException(422, "password must be at least 8 characters") + if session.scalar(select(Account.id).where(Account.username == username)): + raise HTTPException(409, "username already exists") + account = Account( + id=str(uuid.uuid4()), + username=username, + password_hash=password_hash(body.password), + created_at=now(), + ) + session.add(account) + session.commit() + return {"account": account_json(account), "token": issue_token(session, account)} + + @app.post("/api/auth/token") + def login(body: Credentials, session: Session = Depends(db)): + account = session.scalar(select(Account).where(Account.username == body.username)) + if account is None or not password_matches(body.password, account.password_hash): + raise HTTPException(401, "invalid username or password") + return {"account": account_json(account), "token": issue_token(session, account)} + + @app.delete("/api/auth/token", status_code=204) + def revoke( + authorization: Annotated[str | None, Header()] = None, + _account: Account = Depends(required_account), + session: Session = Depends(db), + ): + assert authorization is not None + session.execute(delete(Token).where(Token.digest == token_digest(authorization[7:]))) + session.commit() + + @app.get("/api/account") + def get_account(account: Account = Depends(required_account)): + return {"account": account_json(account)} + + @app.get("/api/users/me") + def get_current_user(account: Account = Depends(required_account)): + return {"user": {"username": account.username}} + + @app.get("/api/users/{username}/projects") + def get_user_projects( + username: str, + limit: Annotated[int, Query(ge=1, le=100)] = 24, + offset: Annotated[int, Query(ge=0)] = 0, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + owner = session.scalar(select(Account).where(Account.username == username)) + if owner is None: + raise HTTPException(404, "user not found") + own = account is not None and account.id == owner.id + query = select(Project).where(Project.owner_id == owner.id) + if not own: + query = query.where(Project.visibility == "public") + projects = session.scalars( + query.order_by(Project.updated_at.desc()).offset(offset).limit(limit) + ).all() + return {"projects": [project_json(project) for project in projects]} + + @app.post("/api/projects", status_code=201) + def post_project( + body: ProjectCreate, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + return { + "project": project_json( + create_project(session, account, body.content, body.filename, body.visibility) + ) + } + + @app.get("/api/projects") + def list_projects( + limit: Annotated[int, Query(ge=1, le=100)] = 24, + offset: Annotated[int, Query(ge=0)] = 0, + featured: bool = False, + mine: bool = False, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + query = select(Project) + count = select(func.count()).select_from(Project) + if mine: + if account is None: + raise HTTPException(401, "authentication required") + query, count = ( + query.where(Project.owner_id == account.id), + count.where(Project.owner_id == account.id), + ) + else: + query, count = ( + query.where(Project.visibility == "public"), + count.where(Project.visibility == "public"), + ) + if featured: + query, count = ( + query.where(Project.featured.is_(True)), + count.where(Project.featured.is_(True)), + ) + projects = session.scalars( + query.order_by(Project.updated_at.desc()).offset(offset).limit(limit) + ).all() + return { + "projects": [project_json(p) for p in projects], + "limit": limit, + "offset": offset, + "total": session.scalar(count), + } + + @app.get("/api/projects/{project_id}") + def get_project( + project_id: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + return {"project": project_json(visible(session.get(Project, project_id), account))} + + @app.patch("/api/projects/{project_id}") + def patch_project( + project_id: str, + body: ProjectPatch, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + project = owned(session.get(Project, project_id), account) + updates = body.model_dump(exclude_unset=True) + if "title" in updates: + if not updates["title"] or not updates["title"].strip(): + raise HTTPException(422, "title must not be empty") + project.title = updates["title"].strip() + if "description" in updates: + project.description = updates["description"] or "" + if "visibility" in updates: + project.visibility = updates["visibility"] + if "tags" in updates: + tags = updates["tags"] + if len(tags) > 20 or any(not tag or len(tag) > 40 for tag in tags): + raise HTTPException(422, "tags must contain at most 20 non-empty 40-character tags") + project.tags_json = json.dumps(tags) + project.updated_at = now() + session.commit() + return {"project": project_json(project)} + + @app.put("/api/projects/{project_id}/content", status_code=201) + def update_content( + project_id: str, + body: ContentUpdate, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + project = owned(session.get(Project, project_id), account) + parse_content(body.content, max_project_bytes) + number = len(project.versions) + 1 + key = f"projects/{project.id}/versions/{number}.json" + object_storage.put(key, body.content.encode(), "application/json") + project.updated_at = now() + session.add(Version(project_id=project.id, number=number, object_key=key, created_at=now())) + session.commit() + session.refresh(project) + return {"project": project_json(project), "version": number} + + @app.delete("/api/projects/{project_id}", status_code=204) + def delete_project_route( + project_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + project = owned(session.get(Project, project_id), account) + session.delete(project) + session.commit() + object_storage.delete_project(project_id) + + @app.post("/api/projects/{project_id}/forks", status_code=201) + def fork_project( + project_id: str, + body: ForkRequest, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + source = visible(session.get(Project, project_id), account) + content = object_storage.get(source.versions[-1].object_key).decode() + fork = create_project( + session, + account, + content, + source.title + ".geolibre.json", + body.visibility, + commit=False, + ) + source.fork_count += 1 + session.commit() + session.refresh(fork) + return {"project": project_json(fork)} + + def raw_response(project: Project, version: Version, immutable: bool) -> Response: + try: + content = object_storage.get(version.object_key) + except KeyError: + raise HTTPException(404, "project content not found") + cache = ( + "public, max-age=3600" + if immutable and project.visibility != "private" + else "private, no-store" + if project.visibility == "private" + else "public, max-age=60" + ) + return Response(content, media_type="application/json", headers={"Cache-Control": cache}) + + @app.get("/api/projects/{project_id}/versions/{number}") + def get_version( + project_id: str, + number: int, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + project = visible(session.get(Project, project_id), account) + version = session.get(Version, (project_id, number)) + if version is None: + raise HTTPException(404, "project version not found") + return raw_response(project, version, True) + + @app.put("/api/projects/{project_id}/thumbnail", status_code=204) + async def put_thumbnail( + project_id: str, + request: Request, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + project = owned(session.get(Project, project_id), account) + content_type = request.headers.get("content-type", "").split(";")[0] + if content_type not in IMAGE_TYPES: + raise HTTPException(422, "thumbnail must be PNG, JPEG, or WebP") + data = await request.body() + if len(data) > max_thumbnail_bytes: + raise HTTPException(413, f"thumbnail exceeds the {max_thumbnail_bytes} byte limit") + object_storage.put(f"projects/{project.id}/thumbnail", data, content_type) + project.thumbnail_type = content_type + project.updated_at = now() + session.commit() + + @app.get("/api/projects/{project_id}/thumbnail") + def get_thumbnail( + project_id: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + project = visible(session.get(Project, project_id), account) + if not project.thumbnail_type: + raise HTTPException(404, "thumbnail not found") + try: + data = object_storage.get(f"projects/{project.id}/thumbnail") + except KeyError: + raise HTTPException(404, "thumbnail not found") + cache = "private, no-store" if project.visibility == "private" else "public, max-age=3600" + return Response(data, media_type=project.thumbnail_type, headers={"Cache-Control": cache}) + + @app.delete("/api/projects/{project_id}/thumbnail", status_code=204) + def delete_thumbnail( + project_id: str, + account: Account = Depends(required_account), + session: Session = Depends(db), + ): + project = owned(session.get(Project, project_id), account) + object_storage.delete(f"projects/{project.id}/thumbnail") + project.thumbnail_type = None + project.updated_at = now() + session.commit() + + @app.get("/{username}/{slug}.geolibre.json") + def latest_raw( + username: str, + slug: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + project = session.scalar( + select(Project).join(Account).where(Account.username == username, Project.slug == slug) + ) + project = visible(project, account) + project.views += 1 + session.commit() + return raw_response(project, project.versions[-1], False) + + @app.get("/{username}/{slug}") + def project_page( + username: str, + slug: str, + account: Account | None = Depends(optional_account), + session: Session = Depends(db), + ): + project = session.scalar( + select(Project).join(Account).where(Account.username == username, Project.slug == slug) + ) + project = visible(project, account) + raw = f"{base_url}/{quote(username)}/{quote(slug)}.geolibre.json" + return RedirectResponse(viewer_url + "?project=" + quote(raw, safe=""), status_code=302) + + return app + + +app = create_app() + + +def run() -> None: + import uvicorn + + uvicorn.run("geolibre_server_api.main:app", host="0.0.0.0", port=8000) diff --git a/backend/geolibre_server_api/pyproject.toml b/backend/geolibre_server_api/pyproject.toml new file mode 100644 index 0000000000..72067f9c5c --- /dev/null +++ b/backend/geolibre_server_api/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "geolibre-server-api" +version = "0.1.0" +description = "Reference GeoLibre projects and identity API" +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["fastapi>=0.115", "sqlalchemy>=2.0", "uvicorn[standard]>=0.32"] + +[project.optional-dependencies] +postgres = ["psycopg[binary]>=3.2"] +s3 = ["boto3>=1.35"] +test = ["httpx>=0.27", "pytest>=8"] + +[project.scripts] +geolibre-server-api = "geolibre_server_api.main:run" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["geolibre_server_api"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py new file mode 100644 index 0000000000..c7cc127bdf --- /dev/null +++ b/backend/geolibre_server_api/tests/test_api.py @@ -0,0 +1,162 @@ +import hashlib +import json + +import pytest +from fastapi.testclient import TestClient +from geolibre_server_api.main import create_app + + +@pytest.fixture +def client(tmp_path): + app = create_app( + f"sqlite:///{tmp_path / 'test.db'}", + public_url="https://share.example", + storage=None, + ) + # create_app reads the storage path only if no implementation is passed. + app.state.storage.root = tmp_path / "objects" + app.state.storage.root.mkdir() + with TestClient(app) as test_client: + yield test_client + + +def account(client, username="ada"): + response = client.post( + "/api/accounts", json={"username": username, "password": "correct horse"} + ) + assert response.status_code == 201 + return response.json()["token"] + + +def auth(token): + return {"Authorization": f"Bearer {token}"} + + +def create_project(client, token, visibility="public", title="Wetlands"): + content = json.dumps({"version": "1.0", "title": title, "layers": []}) + response = client.post( + "/api/projects", + headers=auth(token), + json={ + "filename": "fallback.geolibre.json", + "content": content, + "visibility": visibility, + }, + ) + assert response.status_code == 201, response.text + return response.json()["project"], content + + +def test_accounts_login_current_user_and_hashed_secrets(client): + assert client.get("/health").json() == {"ok": True, "service": "geolibre-server"} + + token = account(client) + assert client.get("/api/account", headers=auth(token)).json()["account"]["username"] == "ada" + assert client.get("/api/users/me", headers=auth(token)).json() == {"user": {"username": "ada"}} + login = client.post("/api/auth/token", json={"username": "ada", "password": "correct horse"}) + assert login.status_code == 200 + assert login.json()["token"] != token + + with client.app.state.engine.connect() as connection: + password = connection.exec_driver_sql("select password_hash from accounts").scalar() + digests = set(connection.exec_driver_sql("select digest from tokens").scalars()) + assert "correct horse" not in password + assert hashlib.sha256(token.encode()).hexdigest() in digests + assert token not in digests + + assert client.delete("/api/auth/token", headers=auth(token)).status_code == 204 + assert client.get("/api/account", headers=auth(token)).status_code == 401 + + +def test_project_crud_visibility_listing_and_raw_views(client): + owner = account(client) + other = account(client, "grace") + project, content = create_project(client, owner, "private") + project_id = project["id"] + assert project["rawJsonUrl"] == "https://share.example/ada/wetlands.geolibre.json" + assert client.get(f"/api/projects/{project_id}").status_code == 404 + assert client.get(f"/api/projects/{project_id}", headers=auth(owner)).status_code == 200 + assert client.get("/api/projects").json()["projects"] == [] + assert len(client.get("/api/projects?mine=true", headers=auth(owner)).json()["projects"]) == 1 + assert len(client.get("/api/users/ada/projects", headers=auth(owner)).json()["projects"]) == 1 + assert client.get("/api/users/ada/projects", headers=auth(other)).json()["projects"] == [] + + patched = client.patch( + f"/api/projects/{project_id}", + headers=auth(owner), + json={ + "visibility": "public", + "description": "A project", + "tags": ["water"], + }, + ) + assert patched.status_code == 200 + assert patched.json()["project"]["tags"] == ["water"] + raw = client.get("/ada/wetlands.geolibre.json") + assert raw.status_code == 200 and raw.json() == json.loads(content) + assert client.get(f"/api/projects/{project_id}").json()["project"]["views"] == 1 + assert ( + client.patch(f"/api/projects/{project_id}", headers=auth(other), json={}).status_code == 403 + ) + + updated = client.put( + f"/api/projects/{project_id}/content", + headers=auth(owner), + json={"content": '{"version":"1.0","title":"Updated"}'}, + ) + assert updated.status_code == 201 and updated.json()["version"] == 2 + historical = client.get(f"/api/projects/{project_id}/versions/1") + assert historical.headers["cache-control"] == "public, max-age=3600" + assert historical.json() == json.loads(content) + assert client.delete(f"/api/projects/{project_id}", headers=auth(owner)).status_code == 204 + assert client.get(f"/api/projects/{project_id}").status_code == 404 + + +def test_thumbnail_fork_and_slug_collision(client): + owner = account(client) + recipient = account(client, "grace") + project, _ = create_project(client, owner) + second, _ = create_project(client, owner) + assert second["slug"] == "wetlands-2" + + thumbnail = b"\x89PNG\r\n\x1a\nnot-a-full-image" + path = f"/api/projects/{project['id']}/thumbnail" + assert ( + client.put( + path, headers={**auth(owner), "Content-Type": "image/png"}, content=thumbnail + ).status_code + == 204 + ) + result = client.get(path) + assert result.content == thumbnail and result.headers["content-type"] == "image/png" + assert ( + client.put( + path, headers={**auth(owner), "Content-Type": "text/plain"}, content=b"x" + ).status_code + == 422 + ) + + forked = client.post( + f"/api/projects/{project['id']}/forks", + headers=auth(recipient), + json={"visibility": "private"}, + ) + assert forked.status_code == 201 + assert forked.json()["project"]["username"] == "grace" + assert client.get(f"/api/projects/{project['id']}").json()["project"]["forkCount"] == 1 + assert client.delete(path, headers=auth(owner)).status_code == 204 + assert client.get(path).status_code == 404 + + +def test_validation_and_errors_use_contract_shape(client): + assert client.post( + "/api/accounts", json={"username": "Bad Name", "password": "long enough"} + ).json()["error"] + token = account(client) + bad = client.post( + "/api/projects", + headers=auth(token), + json={"filename": "x.json", "content": "not json", "visibility": "public"}, + ) + assert bad.status_code == 422 and set(bad.json()) == {"error"} + assert client.get("/api/projects?limit=101").status_code == 422 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..f894d60435 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,100 @@ +name: geolibre + +services: + geolibre-web: + build: + context: . + dockerfile: Dockerfile + image: geolibre-web:local + ports: + - "${GEOLIBRE_WEB_PORT:-8080}:80" + environment: + # These are browser-reachable URLs, not Compose service names. Override + # them with the public TLS origins when deploying behind an ingress. + GEOLIBRE_SHARE_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" + GEOLIBRE_COLLAB_URL: "${GEOLIBRE_COLLAB_URL:-ws://localhost:8787}" + depends_on: + geolibre-server: + condition: service_healthy + geolibre-collab: + condition: service_healthy + restart: unless-stopped + + geolibre-server: + build: + context: backend/geolibre_server_api + image: geolibre-server:local + ports: + - "${GEOLIBRE_SERVER_PORT:-8000}:8000" + environment: + GEOLIBRE_DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-geolibre}:${POSTGRES_PASSWORD:-geolibre}@postgres:5432/${POSTGRES_DB:-geolibre}" + GEOLIBRE_STORAGE_PATH: /data/objects + GEOLIBRE_PUBLIC_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" + GEOLIBRE_VIEWER_URL: "${GEOLIBRE_VIEWER_URL:-http://localhost:8080}" + GEOLIBRE_CORS_ORIGINS: "${GEOLIBRE_CORS_ORIGINS:-http://localhost:8080}" + volumes: + - geolibre-projects:/data/objects + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + + geolibre-collab: + build: + context: . + dockerfile: workers/collab-node/Dockerfile + image: geolibre-collab:local + ports: + - "${GEOLIBRE_COLLAB_PORT:-8787}:8787" + environment: + PORT: "8787" + COLLAB_DB_PATH: /data/collab.sqlite + COLLAB_MAX_SNAPSHOT_BYTES: "${COLLAB_MAX_SNAPSHOT_BYTES:-1000000}" + COLLAB_IDLE_TTL_MS: "${COLLAB_IDLE_TTL_MS:-7200000}" + volumes: + - geolibre-collab:/data + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:8787/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 5s + restart: unless-stopped + + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: "${POSTGRES_USER:-geolibre}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-geolibre}" + POSTGRES_DB: "${POSTGRES_DB:-geolibre}" + volumes: + - geolibre-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + +volumes: + geolibre-projects: + geolibre-collab: + geolibre-postgres: diff --git a/docs/collaboration.md b/docs/collaboration.md index 8a2cdb3071..d94ce58c5c 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -223,7 +223,16 @@ no manual CSP edit. See ## Deploying the relay (`collab.geolibre.app`) -The relay deploys to Cloudflare Workers the same way as `workers/viewer`: +Two hosts implement the same protocol and import the same permission/validation +core: + +- `workers/collab` is the Cloudflare Durable Object used by the hosted service. +- `workers/collab-node` is the self-hostable Node/SQLite relay. It is included + as `geolibre-collab` in the root `docker-compose.yml`; its persistent database + is mounted at `/data/collab.sqlite`. Configure `COLLAB_MAX_SNAPSHOT_BYTES` + (default 1,000,000) and `COLLAB_IDLE_TTL_MS` (default two hours) when needed. + +The hosted relay deploys to Cloudflare Workers the same way as `workers/viewer`: - **CI:** `.github/workflows/deploy-collab.yml` deploys on any push to `main` that touches `workers/collab/**` (or via manual `workflow_dispatch`). It reuses @@ -258,11 +267,13 @@ environment. Until that env var is set, the feature stays dark. Automated: -- `npm run test:worker` typechecks `workers/collab`. +- `npm run test:worker` typechecks both relays and runs the Node relay's socket + integration suite. - `npm run test:frontend` runs `tests/collab-protocol.test.ts` (protocol round-trip including the `set-participant-mode` / `chat` frames, `resolveCollabBaseUrl` validation, echo-suppression logic, and the - `participantCanEdit` effective-permission helper). + `participantCanEdit` effective-permission helper), plus the shared relay + conformance suite. ### Testing the full feature locally @@ -270,13 +281,20 @@ Collaboration is dark until `VITE_GEOLIBRE_COLLAB_URL` points at a running relay, so local testing has two parts: run the relay, then run the app against it. -1. **Start the relay** (the Durable Object) in one terminal: +1. **Start a relay** in one terminal. For the Cloudflare implementation: ```bash cd workers/collab && npx wrangler dev --port 8787 --local # → Ready on http://localhost:8787 ``` + Or run the self-hostable Node implementation: + + ```bash + npm run build -w geolibre-collab-node + npm start -w geolibre-collab-node + ``` + 2. **Start the app pointing at that relay** in another terminal: ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index a21cd1530f..b966ac72bb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -308,11 +308,39 @@ does not satisfy this **fails the container boot** with an error naming the variable, rather than starting up and quietly using the public hosted service with your users' projects. -> There is no open-source implementation of the sharing server API yet -> ([#1685](https://github.com/opengeos/GeoLibre/issues/1685)), so today -> `GEOLIBRE_SHARE_URL` is for pointing at a compatible or staging deployment you -> already run. `GEOLIBRE_COLLAB_URL` can point at your own deployment of -> `workers/collab`. +GeoLibre includes reference implementations of both services. From the +repository root, start the web app, projects server, Node collaboration relay, +and Postgres together: + +```bash +docker compose up --build +``` + +Open `http://localhost:8080`. The projects API is exposed at +`http://localhost:8000` and the relay at `ws://localhost:8787`; the web +container's runtime configuration is populated with those browser-reachable +URLs. For a real deployment, set `GEOLIBRE_SHARE_URL`, +`GEOLIBRE_COLLAB_URL`, `GEOLIBRE_VIEWER_URL`, and +`GEOLIBRE_CORS_ORIGINS` to the public TLS origins before starting Compose. +Change `POSTGRES_PASSWORD` from its development default. + +The projects API can also run as one small SQLite-backed container, without +Postgres: + +```bash +docker build -t geolibre-server backend/geolibre_server_api +docker run --rm -p 8000:8000 \ + -v geolibre-server-data:/data \ + -e GEOLIBRE_PUBLIC_URL=http://localhost:8000 \ + -e GEOLIBRE_VIEWER_URL=http://localhost:8080 \ + geolibre-server +``` + +Its Docker image defaults to a SQLite database and filesystem objects under +`/data`. See the complete [server API contract](server-api.md) and the +service's +[`README`](https://github.com/opengeos/GeoLibre/tree/main/backend/geolibre_server_api) +for Postgres and S3-compatible storage configuration. ### Run the desktop app diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 0000000000..a74effec6b --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,242 @@ +# GeoLibre projects and identity API + +This document defines version 1 of the HTTP contract used by GeoLibre's +Project Gallery and **Project → Share** flow. A compatible server may use any +implementation or storage engine. The reference implementation lives in +`backend/geolibre_server_api`. + +## Conventions + +- The base URL is configured with `GEOLIBRE_SHARE_URL` at container runtime + (or `VITE_GEOLIBRE_SHARE_URL` at build time). +- JSON request and response bodies use `application/json` and camel-case keys. +- Dates are UTC ISO 8601 strings. +- Authenticated endpoints accept a personal API token in + `Authorization: Bearer `. +- Error responses are JSON objects with an `error` string. `401` means a + missing, invalid, or expired token; `403` means the authenticated principal + lacks permission; `404` deliberately covers both a missing project and a + project the caller may not discover; `409` is a uniqueness conflict; `422` + is malformed input; and `429` is rate limiting. +- Servers should send `Cache-Control: public, max-age=3600` on immutable raw + project versions and may use `ETag`/conditional requests. Private responses + must use `Cache-Control: private, no-store`. +- CORS deployments must allow `Authorization` and `Content-Type` from the + GeoLibre web origin. Native desktop requests do not depend on CORS. + +## Limits + +| Field | Limit | +| --- | ---: | +| project title (derived from the uploaded project) | 100 Unicode code points | +| username | 3–39 lowercase ASCII letters, digits, or hyphens | +| slug | 1–100 lowercase ASCII letters, digits, or hyphens | +| description | 2,000 Unicode code points | +| tags | 20 tags, 40 Unicode code points each | +| project document | 50 MiB UTF-8 JSON | +| thumbnail | 5 MiB; PNG, JPEG, or WebP | +| `limit` | default 24, maximum 100 | + +Servers may configure a smaller upload limit, but must return `413` and an +`error` explaining that limit. + +## Visibility + +- `public`: discoverable in the public listing and readable without auth. +- `unlisted`: omitted from public listings, but readable by anyone holding its + URL. It appears in the owner's authenticated listing. +- `private`: readable and mutable only by its owner. Raw and thumbnail URLs + require the same Bearer token as the metadata endpoint. + +Changing visibility affects every version immediately. A raw URL is therefore +not a capability URL for a private project. + +## Identity + +### `POST /api/accounts` + +Creates an account and returns a token once. This endpoint may be disabled when +an installation delegates identity to an external provider. + +```json +{ + "username": "ada", + "password": "correct horse battery staple" +} +``` + +Response `201`: + +```json +{ + "account": {"id": "uuid", "username": "ada", "createdAt": "2026-08-03T12:00:00Z"}, + "token": "secret-token" +} +``` + +### `POST /api/auth/token` + +Exchanges account credentials for a personal API token. + +```json +{"username": "ada", "password": "correct horse battery staple"} +``` + +Response `200` has the same shape as account creation. Tokens are opaque and +must be stored hashed by the server. + +### `DELETE /api/auth/token` + +Revokes the presented Bearer token. Response: `204`. + +### `GET /api/users/me` + +Returns the account associated with the token: + +```json +{"user": {"id": "uuid", "username": "ada", "createdAt": "2026-08-03T12:00:00Z"}} +``` + +An identity provider may create accounts without a username. Project creation +for such an account must return `400` with an error containing the stable, +case-insensitive sentinel text `username required`. Existing clients recognize +that phrase and direct the user to account settings. + +## Projects + +### Project representation + +```json +{ + "id": "uuid", + "username": "ada", + "slug": "wetlands", + "title": "Wetlands", + "description": "", + "visibility": "public", + "thumbnailUrl": "/api/projects/uuid/thumbnail", + "views": 12, + "forkCount": 0, + "versionCount": 1, + "featured": false, + "createdAt": "2026-08-03T12:00:00Z", + "updatedAt": "2026-08-03T12:00:00Z", + "tags": [], + "rawJsonUrl": "https://example.org/ada/wetlands.geolibre.json", + "projectUrl": "https://example.org/ada/wetlands", + "viewerUrl": "https://example.org/?project=https%3A%2F%2Fexample.org%2Fada%2Fwetlands.geolibre.json" +} +``` + +URLs are absolute except that `thumbnailUrl` may be root-relative. Consumers +must resolve a relative thumbnail URL against the server base URL. Unknown +fields must be ignored. + +### `POST /api/projects` + +Requires auth. Creates a project and its first immutable version. + +```json +{ + "filename": "Wetlands.geolibre.json", + "content": "{\"version\":\"1.0\", ...}", + "visibility": "public" +} +``` + +`content` is a string containing a valid GeoLibre project JSON document. +`filename` supplies a fallback title/slug; the project document's non-empty +title is authoritative. `visibility` is required and is `public`, `unlisted`, +or `private`. + +Response `201`: + +```json +{"project": {"id": "uuid", "username": "ada", "slug": "wetlands", "projectUrl": "...", "viewerUrl": "...", "rawJsonUrl": "..."}} +``` + +The `project` object is the full project representation. In particular, +`projectUrl` and `rawJsonUrl` are required because the current client treats a +successful response without them as invalid. + +### `GET /api/projects` + +Returns a page in newest-updated-first order: + +```json +{"projects": [], "limit": 24, "offset": 0, "total": 0} +``` + +Query parameters: + +- `limit`: integer page size. +- `offset`: non-negative number of matching records to skip. +- `featured=true`: return featured projects only. + +Only public projects are returned. An Authorization header does not broaden a +public listing by itself. Invalid pagination is `422`. + +### `GET /api/users/{username}/projects` + +Requires auth. When `{username}` is the caller's username, returns +`{"projects": [...]}` containing all projects owned by the caller, including +unlisted and private projects, in newest-updated-first order. The current client +first resolves its username through `GET /api/users/me`, then calls this route. +Requesting another user's non-public listing returns `403` (an implementation +may instead return that user's public projects if it documents that extension). + +### `GET /api/projects/{id}` + +Returns `{"project": }` if visible to the caller. + +### `PATCH /api/projects/{id}` + +Requires ownership. Accepted fields are `title`, `description`, `visibility`, +and `tags`. Response: `{"project": }`. + +### `PUT /api/projects/{id}/content` + +Requires ownership. Creates a new immutable version. + +```json +{"content": "{\"version\":\"1.0\", ...}"} +``` + +Response `201`: `{"project": , "version": }`. + +### `DELETE /api/projects/{id}` + +Requires ownership. Deletes metadata and stored objects. Response: `204`. + +### `POST /api/projects/{id}/forks` + +Requires auth. Creates a new project owned by the caller from the visible +source's latest content. It accepts `{"visibility":"private"}` (default +`private`) and returns `201` with `{"project": }`. The source +`forkCount` increases atomically. + +### Raw project and website-compatible routes + +- `GET /{username}/{slug}.geolibre.json` returns the latest project document + with `Content-Type: application/json`. +- `GET /api/projects/{id}/versions/{version}` returns an immutable historical + document. +- `GET /{username}/{slug}` may return an HTML project page or redirect to the + configured GeoLibre viewer. It is the `projectUrl` advertised by the API. + +Every successful read of the latest raw document may increment `views`; servers +must not count failed or unauthorized reads. + +### Thumbnails + +`PUT /api/projects/{id}/thumbnail` requires ownership and accepts the image +bytes with their image content type. `GET /api/projects/{id}/thumbnail` follows +project visibility. `DELETE` removes it. Upload and delete responses are `204`. + +## Compatibility + +The API is additive within version 1. Implementations must not repurpose fields +or narrow visibility rules. New optional fields and endpoints may be added. +Breaking changes require a new `/api/v2` namespace. The conformance baseline is +the frontend tests for `share-geolibre.ts` and `share-gallery.ts`, plus the +reference server's API tests. diff --git a/package-lock.json b/package-lock.json index 809222b03d..73457fed55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -415,7 +415,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.63.tgz", "integrity": "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", @@ -662,7 +661,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2798,7 +2796,6 @@ "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.3.7.tgz", "integrity": "sha512-eRzddMlHuBBFeSfhBig5V/32psav5JLvRYjuMqHgcwWXKfanAcxsKBfDSA4tzwWpnDU4id9sfdGW1RDzbVgY5Q==", "license": "MIT", - "peer": true, "dependencies": { "@luma.gl/shadertools": "^9.3.3", "@math.gl/core": "^4.1.0", @@ -3038,7 +3035,6 @@ "resolved": "https://registry.npmjs.org/@developmentseed/morecantile/-/morecantile-0.7.0.tgz", "integrity": "sha512-m9tWDase9COlP/EZ2KK/ydraRU/5yfwvmF/y/2g/iFMFW1vYHQTW/8JYjmo84SiJXUifDDY2xoA3ErYlcWJctg==", "license": "MIT", - "peer": true, "dependencies": { "@developmentseed/affine": "^0.7.0" } @@ -3167,8 +3163,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz", "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-postgis": { "version": "0.2.4", @@ -3784,7 +3779,6 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-4.10.3.tgz", "integrity": "sha512-o5iwxSDS2M8lwLkNtaJTKqyN3NHv2Ne5bL+7tfdcmmHukcGMuuSroRG/+IT3CXOS7gk9sYVdR0cMEPHs1lTWNQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -3800,7 +3794,6 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-request/-/arcgis-rest-request-4.10.3.tgz", "integrity": "sha512-gBKSRC7L3cD1KX4fIleRiEKtzlvKjuDW5cYpDcDyQCn/Bw7bJzlGrlaMw87FQ+ReTZp9vlTYFXyrGMuUtR0dKw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@esri/arcgis-rest-fetch": "^4.10.3", "@esri/arcgis-rest-form-data": "^4.10.3", @@ -3904,6 +3897,10 @@ "apache-arrow": ">=15" } }, + "node_modules/@geolibre/collab-core": { + "resolved": "packages/collab-core", + "link": true + }, "node_modules/@geolibre/core": { "resolved": "packages/core", "link": true @@ -3933,7 +3930,6 @@ "resolved": "https://registry.npmjs.org/@geoman-io/maplibre-geoman-free/-/maplibre-geoman-free-0.8.4.tgz", "integrity": "sha512-nOLEpMtORSR0XceXfOHc0RGqrkCQo3719o1d1Bp64xzijxn37KvkxB7S3dwOFV+i3Z3MleaSGAcIjvszil2D3A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/area": "^7.3.5", "@turf/bbox": "^7.3.5", @@ -5546,7 +5542,6 @@ "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/core": "4.1.0" } @@ -5577,7 +5572,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", @@ -5706,7 +5700,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -7647,7 +7640,6 @@ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", @@ -10729,6 +10721,16 @@ "integrity": "sha512-Y7L/frVydXRd16MevczslJZQu+QWsrqZlj6ytk7mST3xen0fkx7Ollw31By/89A8Wq+nfNWm/IoTR1ac/0fRhA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -10774,7 +10776,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -11456,7 +11457,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11589,7 +11589,6 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.2.0.tgz", "integrity": "sha512-Hxe6Agq26gQOM954qpzYSllJBPJl+e16U5CkfuMUhLrNba+5nKkttIVlflaovN6oaTratqMGAO8H5u/aNhmHWQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/node": "^25.2.0", "flatbuffers": "^25.1.24", @@ -12024,7 +12023,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -12302,7 +12300,6 @@ "resolved": "https://registry.npmjs.org/cog-tiler-wasm/-/cog-tiler-wasm-0.3.1.tgz", "integrity": "sha512-5mH36bBRrArqeCeCW3Gl2IhlWcU5g64eQRIfIsyo+XQwaBYfmjzuHJ36nbS7fCLUNk9khNUdgVWTU9C1zS33iw==", "license": "MIT", - "peer": true, "peerDependencies": { "geotiff": "^2.1.0 || ^3.0.0", "geotiff-geokeys-to-proj4": "^2024.4.13", @@ -13290,7 +13287,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13361,7 +13357,6 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -13632,7 +13627,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -14331,6 +14325,10 @@ "resolved": "workers/ai-proxy", "link": true }, + "node_modules/geolibre-collab-node": { + "resolved": "workers/collab-node", + "link": true + }, "node_modules/geolibre-collab-worker": { "resolved": "workers/collab", "link": true @@ -14361,7 +14359,6 @@ "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", "license": "MIT", - "peer": true, "dependencies": { "@petamoriken/float16": "^3.9.3", "lerc": "^3.0.0", @@ -14380,8 +14377,7 @@ "version": "2024.4.13", "resolved": "https://registry.npmjs.org/geotiff-geokeys-to-proj4/-/geotiff-geokeys-to-proj4-2024.4.13.tgz", "integrity": "sha512-Jgtm/lcPkgB44wCqQHaVQx5/fyhmiVDRUKQcI/vMolsED8/GRWBnn5qkQo/CgutQg9xkGzig21DY9Px9mFRdvg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/geotiff/node_modules/lerc": { "version": "3.0.0", @@ -15164,7 +15160,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz", "integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -15385,7 +15380,6 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, @@ -18157,7 +18151,6 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-6.48.0.tgz", "integrity": "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", @@ -18546,7 +18539,6 @@ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -18725,7 +18717,6 @@ "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.21.0.tgz", "integrity": "sha512-33HfDftqw8kY+Cl1dcL16SJuqTSzYxmz4re7Nmhk+fs1/N1fFrAkkF579msQTR/4fLeJVSNne8/gpoCz0fk1uw==", "license": "MIT", - "peer": true, "dependencies": { "mgrs": "1.0.0", "wkt-parser": "^1.5.5" @@ -18937,7 +18928,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18947,7 +18937,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19348,7 +19337,6 @@ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -20352,7 +20340,6 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -20542,7 +20529,6 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -20813,7 +20799,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -21086,7 +21071,6 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -21490,7 +21474,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21705,7 +21688,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -21789,7 +21771,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -21879,7 +21860,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -21957,7 +21937,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -22020,7 +21999,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, @@ -22045,6 +22023,48 @@ } } }, + "packages/collab-core": { + "name": "@geolibre/collab-core", + "version": "0.0.0", + "devDependencies": { + "typescript": "^7.0.2" + } + }, + "packages/collab-core/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "packages/core": { "name": "@geolibre/core", "version": "2.4.1", @@ -22549,11 +22569,510 @@ "workers/collab": { "name": "geolibre-collab-worker", "version": "0.0.0", + "dependencies": { + "@geolibre/collab-core": "0.0.0" + }, "devDependencies": { "@cloudflare/workers-types": "^5.20260728.1", "wrangler": "^4.114.0" } }, + "workers/collab-node": { + "name": "geolibre-collab-node", + "version": "0.0.0", + "dependencies": { + "@geolibre/collab-core": "0.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.18.1", + "esbuild": "^0.27.0" + } + }, + "workers/collab-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "workers/collab-node/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "workers/tiles": { "name": "geolibre-tiles-worker", "version": "0.0.0", diff --git a/package.json b/package.json index df3493371e..feedc2aced 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:frontend:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-lines=78 --test-coverage-branches=78 --test-coverage-functions=63 --test-coverage-exclude=\"tests/**\" --test-coverage-exclude=\"e2e/**\" --test-coverage-exclude=\"**/*.config.*\" tests/*.test.ts", "test:backend": "python -m pytest backend/geolibre_server/tests", "test:backend:coverage": "python -m pytest backend/geolibre_server/tests --cov=geolibre_server --cov-report=term-missing --cov-fail-under=55", - "test:worker": "npm run typecheck -w geolibre-viewer-worker && npm run typecheck -w geolibre-collab-worker && npm run typecheck -w geolibre-tiles-worker && npm run typecheck -w geolibre-ai-proxy-worker", + "test:worker": "npm run typecheck -w geolibre-viewer-worker && npm run typecheck -w geolibre-collab-worker && npm run typecheck -w geolibre-collab-node && npm test -w geolibre-collab-node && npm run typecheck -w geolibre-tiles-worker && npm run typecheck -w geolibre-ai-proxy-worker", "test:e2e": "playwright test", "check:rust": "cargo check --manifest-path apps/geolibre-desktop/src-tauri/Cargo.toml", "ci": "npm run lint && npm run build && npm run test:frontend:coverage && npm run test:worker && npm run test:backend:coverage && npm run check:rust", diff --git a/packages/collab-core/package.json b/packages/collab-core/package.json new file mode 100644 index 0000000000..6d53c659ba --- /dev/null +++ b/packages/collab-core/package.json @@ -0,0 +1,17 @@ +{ + "name": "@geolibre/collab-core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./protocol": "./src/protocol.ts", + "./comment-validate": "./src/comment-validate.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^7.0.2" + } +} diff --git a/packages/collab-core/src/comment-validate.ts b/packages/collab-core/src/comment-validate.ts new file mode 100644 index 0000000000..b57cb555ad --- /dev/null +++ b/packages/collab-core/src/comment-validate.ts @@ -0,0 +1,203 @@ +// Pure validators for comment-mutation payloads. These mirror the +// `ProjectComment` / `CommentReply` shapes from `@geolibre/core` but operate on +// untrusted `unknown` input, returning a sanitized object or `null`. + +/** Body length cap — matches the chat limit so comments can't store unbounded text. */ +export const MAX_COMMENT_BODY_LENGTH = 2000; + +/** Author name length cap — generous for display names but bounded. */ +export const MAX_COMMENT_AUTHOR_LENGTH = 120; + +/** Identifier length cap for comment/reply ids, layerId, and string featureId. */ +export const MAX_ID_LENGTH = 200; + +/** Minimum gap between a socket's comment-mutation frames (ms). */ +export const MIN_COMMENT_INTERVAL_MS = 250; + +/** Maximum number of replies stored per comment. */ +export const MAX_REPLIES_PER_COMMENT = 100; + +/** Maximum number of comments stored per session. Bounds the snapshot growth a + * sustained stream of "add" mutations can cause, the way `CHAT_HISTORY_LIMIT` + * bounds the chat log. */ +export const MAX_COMMENTS_PER_SESSION = 500; + +/** True when `value` is a non-empty string within {@link MAX_ID_LENGTH}. */ +export function isBoundedId(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH; +} + +/** Carry the stored `comments` list into an incoming full-project snapshot that + * doesn't supply one of its own. + * + * The relay writes comments straight into the stored snapshot (see + * `handleCommentMutation`), but `serializeProject` omits the key entirely when + * a peer holds none — so a peer that hasn't merged those broadcasts yet (a race + * with its debounced snapshot, or a client that joined before them) would + * otherwise replace the persisted comments with nothing. A project that carries + * its own `comments` still wins, so a delete is never resurrected. + * + * `stored` is the already-parsed stored snapshot, or `null` when absent/corrupt. + */ +export function preserveStoredComments(project: unknown, stored: unknown): unknown { + if (!project || typeof project !== "object" || Array.isArray(project)) return project; + if ("comments" in project) return project; + if (!stored || typeof stored !== "object" || Array.isArray(stored)) return project; + const comments = (stored as Record).comments; + if (!Array.isArray(comments) || comments.length === 0) return project; + return { ...(project as Record), comments }; +} + +const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +function finite(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n); +} + +// -- anchor ------------------------------------------------------------------- + +interface PointAnchor { + type: "point"; + lngLat: [number, number]; +} + +interface FeatureAnchor { + type: "feature"; + layerId: string; + featureId: string | number; + lngLat?: [number, number]; +} + +export type ValidatedAnchor = PointAnchor | FeatureAnchor; + +export function validateAnchor(raw: unknown): ValidatedAnchor | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + + if (o.type === "point") { + if (!Array.isArray(o.lngLat) || o.lngLat.length !== 2) return null; + const [lng, lat] = o.lngLat; + if (!finite(lng) || !finite(lat)) return null; + return { type: "point", lngLat: [lng, lat] }; + } + + if (o.type === "feature") { + if (!isBoundedId(o.layerId)) return null; + if (typeof o.featureId !== "string" && typeof o.featureId !== "number") return null; + if (typeof o.featureId === "string" && !isBoundedId(o.featureId)) return null; + if (typeof o.featureId === "number" && !finite(o.featureId)) return null; + const anchor: FeatureAnchor = { + type: "feature", + layerId: o.layerId, + featureId: o.featureId, + }; + if (Array.isArray(o.lngLat) && o.lngLat.length === 2) { + const [lng, lat] = o.lngLat; + if (finite(lng) && finite(lat)) { + anchor.lngLat = [lng, lat]; + } + } + return anchor; + } + + return null; +} + +// -- author ------------------------------------------------------------------- + +export interface ValidatedAuthor { + name: string; + color: string; +} + +export function validateAuthor(raw: unknown): ValidatedAuthor | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + if (typeof o.name !== "string") return null; + const name = o.name.trim().slice(0, MAX_COMMENT_AUTHOR_LENGTH); + if (!name) return null; + if (typeof o.color !== "string" || !HEX_COLOR_RE.test(o.color)) return null; + return { name, color: o.color }; +} + +// -- comment ------------------------------------------------------------------ + +export interface ValidatedComment { + id: string; + anchor: ValidatedAnchor; + author: ValidatedAuthor; + body: string; + createdAt: string; + resolved: boolean; + replies: ValidatedReply[]; +} + +export function validateComment(raw: unknown): ValidatedComment | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + + if (!isBoundedId(o.id)) return null; + + const anchor = validateAnchor(o.anchor); + if (!anchor) return null; + + const author = validateAuthor(o.author); + if (!author) return null; + + if (typeof o.body !== "string") return null; + const body = o.body.slice(0, MAX_COMMENT_BODY_LENGTH); + if (!body.trim()) return null; + + const createdAt = + typeof o.createdAt === "string" && !Number.isNaN(Date.parse(o.createdAt)) + ? o.createdAt + : new Date().toISOString(); + + const replies: ValidatedReply[] = []; + if (Array.isArray(o.replies)) { + for (const r of o.replies.slice(0, MAX_REPLIES_PER_COMMENT)) { + const validated = validateReply(r); + if (validated) replies.push(validated); + } + } + + return { + id: o.id, + anchor, + author, + body, + createdAt, + resolved: Boolean(o.resolved), + replies, + }; +} + +// -- reply -------------------------------------------------------------------- + +export interface ValidatedReply { + id: string; + author: ValidatedAuthor; + body: string; + createdAt: string; +} + +export function validateReply(raw: unknown): ValidatedReply | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + + if (!isBoundedId(o.id)) return null; + + const author = validateAuthor(o.author); + if (!author) return null; + + if (typeof o.body !== "string") return null; + const body = o.body.slice(0, MAX_COMMENT_BODY_LENGTH); + if (!body.trim()) return null; + + const createdAt = + typeof o.createdAt === "string" && !Number.isNaN(Date.parse(o.createdAt)) + ? o.createdAt + : new Date().toISOString(); + + return { id: o.id, author, body, createdAt }; +} diff --git a/packages/collab-core/src/index.ts b/packages/collab-core/src/index.ts new file mode 100644 index 0000000000..79ae0a81f3 --- /dev/null +++ b/packages/collab-core/src/index.ts @@ -0,0 +1,3 @@ +export * from "./comment-validate"; +export * from "./protocol"; +export * from "./session"; diff --git a/packages/collab-core/src/protocol.ts b/packages/collab-core/src/protocol.ts new file mode 100644 index 0000000000..f024bf5b63 --- /dev/null +++ b/packages/collab-core/src/protocol.ts @@ -0,0 +1,183 @@ +// Wire protocol for the live-collaboration relay. +// +// This is the worker-side copy. The frontend keeps a parallel copy in +// `apps/geolibre-desktop/src/lib/collab-protocol.ts` with the `project` field +// typed as the concrete `GeoLibreProject`. The relay never inspects a project's +// contents — it only stores and forwards the opaque JSON — so here `project` is +// `unknown`. Keep the two `type` discriminants and field names in sync. + +export type CollaborationRole = "host" | "guest"; +export type CollaborationMode = "view-only" | "co-edit"; + +export interface CollabParticipant { + clientId: string; + displayName: string; + color: string; + role: CollaborationRole; + /** + * Host-set per-participant edit override (#754, Part 3). `null` means "follow + * the session mode"; `true`/`false` pins this participant to can-edit / + * view-only regardless of the session default. Always `null` for the host + * (the host can always edit). + */ + editOverride: boolean | null; +} + +export interface CollabCursor { + lng: number; + lat: number; +} + +/** One in-session chat message (#754, Part 4). Ephemeral session state. */ +export interface CollabChatMessage { + /** Server-assigned id (dedupes optimistic local rendering / React keys). */ + id: string; + /** clientId of the author. */ + clientId: string; + displayName: string; + color: string; + text: string; + /** Optional map coordinate the author attached; clickable in peers' UIs. */ + coordinate?: CollabCursor | null; + /** Server-assigned epoch-ms timestamp. */ + ts: number; +} + +export interface CollabView { + center: [number, number]; + zoom: number; + bearing: number; + pitch: number; + bbox?: [number, number, number, number]; +} + +// Client -> server ----------------------------------------------------------- + +export interface JoinMessage { + type: "join"; + clientId: string; + displayName: string; + color: string; + /** Presented by the session creator to claim the host role. */ + hostToken?: string; +} + +export interface ClientSnapshotMessage { + type: "snapshot"; + project: unknown; + rev: number; +} + +export interface ClientPresenceMessage { + type: "presence"; + cursor?: CollabCursor | null; + view?: CollabView | null; +} + +export interface SetModeMessage { + type: "set-mode"; + mode: CollaborationMode; +} + +/** Host-only: pin one participant to can-edit / view-only (#754, Part 3). */ +export interface SetParticipantModeMessage { + type: "set-participant-mode"; + clientId: string; + canEdit: boolean; +} + +/** Send a chat message to the session (#754, Part 4). */ +export interface ChatSendMessage { + type: "chat"; + text: string; + coordinate?: CollabCursor | null; +} + +export type CommentMutationAction = + | { type: "add"; comment: unknown } + | { type: "reply"; commentId: string; reply: unknown } + | { type: "toggle-resolve"; commentId: string; resolved?: boolean } + | { type: "delete"; commentId: string }; + +export interface CommentMutationMessage { + type: "comment-mutation"; + action: CommentMutationAction; +} + +export type ClientMessage = + | JoinMessage + | ClientSnapshotMessage + | ClientPresenceMessage + | SetModeMessage + | SetParticipantModeMessage + | ChatSendMessage + | CommentMutationMessage; + +// Server -> client ----------------------------------------------------------- + +export interface WelcomeMessage { + type: "welcome"; + clientId: string; + role: CollaborationRole; + mode: CollaborationMode; + participants: CollabParticipant[]; + snapshot: unknown | null; + /** Current presence of existing participants (keyed by clientId) so a late + * joiner sees their cursors/viewports without waiting for the next move. */ + presence: Record; + /** Recent chat history so a late joiner sees the conversation so far (#754). */ + chat: CollabChatMessage[]; + rev: number; +} + +export interface PresenceEntry { + cursor: CollabCursor | null; + view: CollabView | null; +} + +export interface ServerSnapshotMessage { + type: "snapshot"; + project: unknown; + origin: string; + rev: number; +} + +export interface ServerPresenceMessage { + type: "presence"; + clientId: string; + cursor?: CollabCursor | null; + view?: CollabView | null; +} + +export interface ParticipantsMessage { + type: "participants"; + participants: CollabParticipant[]; +} + +export interface ModeMessage { + type: "mode"; + mode: CollaborationMode; +} + +/** Fan-out of a chat message to every participant (including the sender, so the + * server's ordering is authoritative). */ +export interface ChatBroadcastMessage { + type: "chat"; + message: CollabChatMessage; +} + +export interface ErrorMessage { + type: "error"; + code: "forbidden" | "too-large" | "bad-message" | "not-found"; + message: string; +} + +export type ServerMessage = + | WelcomeMessage + | ServerSnapshotMessage + | ServerPresenceMessage + | ParticipantsMessage + | ModeMessage + | ChatBroadcastMessage + | CommentMutationMessage + | ErrorMessage; diff --git a/packages/collab-core/src/session.ts b/packages/collab-core/src/session.ts new file mode 100644 index 0000000000..c7c9fe1f1a --- /dev/null +++ b/packages/collab-core/src/session.ts @@ -0,0 +1,153 @@ +import type { + CollabCursor, + CollabParticipant, + CollabView, + CollaborationMode, + CollaborationRole, +} from "./protocol"; + +export const MAX_SNAPSHOT_BYTES = 1_000_000; +export const EMPTY_SESSION_TTL_MS = 2 * 60 * 60 * 1000; +export const MAX_CHAT_TEXT_LENGTH = 2000; +export const CHAT_HISTORY_LIMIT = 50; +export const MAX_CHAT_STORAGE_BYTES = 100_000; +export const MIN_CHAT_INTERVAL_MS = 250; + +const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +function finite(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n); +} + +/** + * Transport-neutral state attached to one connection. Adapters may keep this + * in memory (Node) or serialize it onto a hibernatable socket (Cloudflare). + */ +export interface SessionParticipant { + clientId: string; + displayName: string; + color: string; + role: CollaborationRole; + editOverride?: boolean; + lastChatTs?: number; + lastCommentTs?: number; +} + +export function participantCanEdit( + participant: Pick, + mode: CollaborationMode, +): boolean { + if (participant.role === "host") return true; + if (participant.editOverride !== undefined) return participant.editOverride; + return mode === "co-edit"; +} + +export type SnapshotDecision = + | { ok: true } + | { ok: false; code: "forbidden" | "too-large"; message: string }; + +/** Shared authorization/size gate used by every relay implementation. */ +export function authorizeSnapshot( + participant: Pick, + mode: CollaborationMode, + byteLength: number, + maxBytes = MAX_SNAPSHOT_BYTES, +): SnapshotDecision { + if (!participantCanEdit(participant, mode)) { + return { + ok: false, + code: "forbidden", + message: + participant.editOverride === false + ? "The host has set you to view-only." + : "This session is view-only.", + }; + } + if (byteLength > maxBytes) { + return { + ok: false, + code: "too-large", + message: "Project is too large to sync live. Share it via URL instead.", + }; + } + return { ok: true }; +} + +export function authorizeHostAction( + participant: Pick, + action: "session mode" | "participant permissions", +): string | null { + return participant.role === "host" ? null : `Only the host can change ${action}.`; +} + +export function normalizeMode(mode: unknown): CollaborationMode { + return mode === "view-only" ? "view-only" : "co-edit"; +} + +export function setParticipantOverride( + actor: Pick, + participants: SessionParticipant[], + clientId: unknown, + canEdit: unknown, +): boolean { + if (actor.role !== "host" || typeof clientId !== "string") return false; + const target = participants.find((participant) => participant.clientId === clientId); + if (!target || target.role === "host") return false; + target.editOverride = canEdit === true; + return true; +} + +export function clearParticipantOverrides(participants: SessionParticipant[]): boolean { + let changed = false; + for (const participant of participants) { + if (participant.editOverride !== undefined) { + participant.editOverride = undefined; + changed = true; + } + } + return changed; +} + +export function toWireParticipant(participant: SessionParticipant): CollabParticipant { + return { + clientId: participant.clientId, + displayName: participant.displayName, + color: participant.color, + role: participant.role, + editOverride: participant.role === "host" ? null : (participant.editOverride ?? null), + }; +} + +export function sanitizeDisplayName(value: unknown): string { + return (typeof value === "string" ? value : "").slice(0, 60) || "Guest"; +} + +export function sanitizeColor(value: unknown): string { + return typeof value === "string" && HEX_COLOR_RE.test(value) ? value : "#888888"; +} + +export function sanitizeCursor(c: unknown): CollabCursor | null { + if (c && typeof c === "object") { + const { lng, lat } = c as Record; + if (finite(lng) && finite(lat)) return { lng, lat }; + } + return null; +} + +export function sanitizeView(v: unknown): CollabView | null { + if (!v || typeof v !== "object") return null; + const o = v as Record; + const center = o.center; + if (!Array.isArray(center) || !finite(center[0]) || !finite(center[1])) return null; + const view: CollabView = { + center: [center[0], center[1]], + zoom: finite(o.zoom) ? o.zoom : 0, + bearing: finite(o.bearing) ? o.bearing : 0, + pitch: finite(o.pitch) ? o.pitch : 0, + }; + const bbox = o.bbox; + if (Array.isArray(bbox) && bbox.length === 4 && bbox.every(finite)) { + view.bbox = [bbox[0], bbox[1], bbox[2], bbox[3]]; + } + return view; +} diff --git a/packages/collab-core/tsconfig.json b/packages/collab-core/tsconfig.json new file mode 100644 index 0000000000..90d3a82812 --- /dev/null +++ b/packages/collab-core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/tests/collab-core-conformance.test.ts b/tests/collab-core-conformance.test.ts new file mode 100644 index 0000000000..d70463d105 --- /dev/null +++ b/tests/collab-core-conformance.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + authorizeHostAction, + authorizeSnapshot, + clearParticipantOverrides, + MAX_SNAPSHOT_BYTES, + normalizeMode, + participantCanEdit, + sanitizeColor, + sanitizeCursor, + sanitizeDisplayName, + setParticipantOverride, + toWireParticipant, + type SessionParticipant, +} from "../packages/collab-core/src/index"; + +function participant(role: "host" | "guest", clientId = role): SessionParticipant { + return { + clientId, + displayName: role, + color: "#123456", + role, + }; +} + +/** + * Transport implementations run these policy-level assertions against the + * shared core. Adapter-specific suites may reuse the same scenarios over real + * sockets; keeping the decisions here makes Cloudflare and Node relays agree. + */ +describe("collaboration relay conformance", () => { + it("rejects a guest snapshot in a view-only session", () => { + assert.deepEqual(authorizeSnapshot(participant("guest"), "view-only", 20), { + ok: false, + code: "forbidden", + message: "This session is view-only.", + }); + }); + + it("applies participant overrides ahead of the session mode", () => { + const guest = participant("guest"); + guest.editOverride = true; + assert.equal(participantCanEdit(guest, "view-only"), true); + assert.deepEqual(authorizeSnapshot(guest, "view-only", 20), { ok: true }); + + guest.editOverride = false; + assert.equal(participantCanEdit(guest, "co-edit"), false); + assert.equal(authorizeSnapshot(guest, "co-edit", 20).ok, false); + }); + + it("gates set-mode and set-participant-mode to the host", () => { + const guest = participant("guest"); + const host = participant("host"); + assert.equal( + authorizeHostAction(guest, "session mode"), + "Only the host can change session mode.", + ); + assert.equal( + authorizeHostAction(guest, "participant permissions"), + "Only the host can change participant permissions.", + ); + assert.equal(authorizeHostAction(host, "session mode"), null); + + const target = participant("guest", "target"); + assert.equal(setParticipantOverride(guest, [target], "target", true), false); + assert.equal(target.editOverride, undefined); + assert.equal(setParticipantOverride(host, [target], "target", true), true); + assert.equal(target.editOverride, true); + assert.equal(setParticipantOverride(host, [host], "host", false), false); + }); + + it("rejects snapshots over the UTF-8 byte ceiling", () => { + const host = participant("host"); + assert.deepEqual(authorizeSnapshot(host, "co-edit", MAX_SNAPSHOT_BYTES), { ok: true }); + const decision = authorizeSnapshot(host, "co-edit", MAX_SNAPSHOT_BYTES + 1); + assert.equal(decision.ok, false); + if (!decision.ok) assert.equal(decision.code, "too-large"); + }); + + it("clears sticky overrides when the host changes the session mode", () => { + const host = participant("host"); + const guest = participant("guest"); + guest.editOverride = true; + assert.equal(clearParticipantOverrides([host, guest]), true); + assert.equal(guest.editOverride, undefined); + assert.equal(clearParticipantOverrides([host, guest]), false); + assert.equal(normalizeMode("invalid"), "co-edit"); + assert.equal(normalizeMode("view-only"), "view-only"); + }); + + it("normalizes participants and sanitizes untrusted join/presence fields", () => { + const guest = participant("guest"); + assert.equal(toWireParticipant(guest).editOverride, null); + assert.equal(sanitizeDisplayName(42), "Guest"); + assert.equal(sanitizeColor("red"), "#888888"); + assert.deepEqual(sanitizeCursor({ lng: -71, lat: 42, extra: "drop" }), { + lng: -71, + lat: 42, + }); + assert.equal(sanitizeCursor({ lng: Number.NaN, lat: 42 }), null); + }); +}); diff --git a/workers/collab-node/Dockerfile b/workers/collab-node/Dockerfile new file mode 100644 index 0000000000..6277f548b8 --- /dev/null +++ b/workers/collab-node/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/collab-core/package.json packages/collab-core/package.json +COPY workers/collab-node/package.json workers/collab-node/package.json +RUN npm ci --workspace geolibre-collab-node --workspace @geolibre/collab-core +COPY packages/collab-core packages/collab-core +COPY workers/collab-node workers/collab-node +RUN npm run build -w geolibre-collab-node + +FROM node:22-bookworm-slim +ENV NODE_ENV=production PORT=8787 COLLAB_DB_PATH=/data/collab.sqlite +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/workers/collab-node/package.json ./workers/collab-node/package.json +COPY --from=build /app/workers/collab-node/dist ./workers/collab-node/dist +VOLUME ["/data"] +EXPOSE 8787 +CMD ["node", "workers/collab-node/dist/server.js"] diff --git a/workers/collab-node/README.md b/workers/collab-node/README.md new file mode 100644 index 0000000000..a78340c584 --- /dev/null +++ b/workers/collab-node/README.md @@ -0,0 +1,24 @@ +# GeoLibre Node collaboration relay + +This is the Docker-friendly, single-process host for GeoLibre's collaboration +protocol. It uses the same `@geolibre/collab-core` policy as the hosted +Cloudflare Worker and persists session state in SQLite. + +```bash +npm run build -w geolibre-collab-node +PORT=8787 COLLAB_DB_PATH=./data/collab.sqlite npm start -w geolibre-collab-node +``` + +Configuration: + +- `PORT` — HTTP/WebSocket port (default `8787`) +- `HOST` — listen address (default `0.0.0.0`) +- `COLLAB_DB_PATH` — SQLite file (default `./data/collab.sqlite`) +- `COLLAB_MAX_SNAPSHOT_BYTES` — maximum UTF-8 snapshot frame size (default + `1000000`, matching the hosted Worker) +- `COLLAB_IDLE_TTL_MS` — time after the last participant disconnects before the + session and its persisted data are deleted (default two hours) + +Endpoints are `POST /sessions`, `GET /sessions/:id/ws`, and `GET /health`. +Persist the directory containing `COLLAB_DB_PATH`, and terminate TLS at the +ingress so browsers can connect with `wss://`. diff --git a/workers/collab-node/package.json b/workers/collab-node/package.json new file mode 100644 index 0000000000..60b5c80792 --- /dev/null +++ b/workers/collab-node/package.json @@ -0,0 +1,20 @@ +{ + "name": "geolibre-collab-node", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node22 --external:ws --outfile=dist/server.js", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test test/*.test.ts", + "start": "node dist/server.js" + }, + "dependencies": { + "@geolibre/collab-core": "0.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.18.1", + "esbuild": "^0.27.0" + } +} diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts new file mode 100644 index 0000000000..406f9e7210 --- /dev/null +++ b/workers/collab-node/src/server.ts @@ -0,0 +1,517 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { randomBytes, randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { + CHAT_HISTORY_LIMIT, + MAX_CHAT_STORAGE_BYTES, + MAX_CHAT_TEXT_LENGTH, + MAX_COMMENTS_PER_SESSION, + MAX_REPLIES_PER_COMMENT, + MAX_SNAPSHOT_BYTES, + MIN_COMMENT_INTERVAL_MS, + MIN_CHAT_INTERVAL_MS, + authorizeHostAction, + authorizeSnapshot, + clearParticipantOverrides, + normalizeMode, + participantCanEdit, + preserveStoredComments, + sanitizeCursor, + sanitizeDisplayName, + sanitizeColor, + sanitizeView, + setParticipantOverride, + toWireParticipant, + validateComment, + validateReply, + isBoundedId, + type ClientMessage, + type CollabChatMessage, + type CollaborationMode, + type PresenceEntry, + type ServerMessage, + type SessionParticipant, +} from "@geolibre/collab-core"; +import { WebSocket, WebSocketServer } from "ws"; +import { SessionStore, type StoredSession } from "./store.js"; + +const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const CODE_LENGTH = 8; +const DEFAULT_IDLE_TTL_MS = 2 * 60 * 60 * 1000; +const ENCODER = new TextEncoder(); + +interface Peer { + socket: WebSocket; + participant?: SessionParticipant; +} + +interface LiveSession { + peers: Set; + presence: Map; + cleanup?: NodeJS.Timeout; +} + +export interface RelayOptions { + port?: number; + host?: string; + dbPath?: string; + maxSnapshotBytes?: number; + idleTtlMs?: number; +} + +function positive(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function randomCode(): string { + const bytes = randomBytes(CODE_LENGTH); + return Array.from(bytes, (byte) => CODE_ALPHABET[byte % CODE_ALPHABET.length]).join(""); +} + +function randomToken(): string { + return randomBytes(24).toString("hex"); +} + +function send(peer: Peer, message: ServerMessage): void { + if (peer.socket.readyState === WebSocket.OPEN) peer.socket.send(JSON.stringify(message)); +} + +function json(response: import("node:http").ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { + "content-type": "application/json", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "Content-Type", + }); + response.end(JSON.stringify(body)); +} + +export function createRelay(options: RelayOptions = {}): { + server: Server; + store: SessionStore; + close: () => Promise; +} { + const dbPath = options.dbPath ?? process.env.COLLAB_DB_PATH ?? "./data/collab.sqlite"; + const maxSnapshotBytes = + options.maxSnapshotBytes ?? positive(process.env.COLLAB_MAX_SNAPSHOT_BYTES, MAX_SNAPSHOT_BYTES); + const idleTtlMs = + options.idleTtlMs ?? positive(process.env.COLLAB_IDLE_TTL_MS, DEFAULT_IDLE_TTL_MS); + const store = new SessionStore(dbPath); + const sessions = new Map(); + const wss = new WebSocketServer({ noServer: true, maxPayload: maxSnapshotBytes + 64_000 }); + + function live(id: string): LiveSession { + let session = sessions.get(id); + if (!session) { + session = { peers: new Set(), presence: new Map() }; + sessions.set(id, session); + } + if (session.cleanup) { + clearTimeout(session.cleanup); + session.cleanup = undefined; + } + return session; + } + + function participants(session: LiveSession): SessionParticipant[] { + return [...session.peers].flatMap((peer) => (peer.participant ? [peer.participant] : [])); + } + + function broadcast(session: LiveSession, message: ServerMessage, except?: Peer): void { + for (const peer of session.peers) if (peer !== except) send(peer, message); + } + + function broadcastParticipants(session: LiveSession, except?: Peer): void { + broadcast( + session, + { + type: "participants", + participants: participants(session).map(toWireParticipant), + }, + except, + ); + } + + function closePeer(id: string, session: LiveSession, peer: Peer): void { + if (!session.peers.delete(peer)) return; + if (peer.participant) session.presence.delete(peer.participant.clientId); + broadcastParticipants(session); + if (session.peers.size === 0) { + session.cleanup = setTimeout(() => { + if (session.peers.size !== 0) return; + sessions.delete(id); + store.delete(id); + }, idleTtlMs); + session.cleanup.unref(); + } + } + + function handleMessage( + id: string, + session: LiveSession, + peer: Peer, + raw: WebSocket.RawData, + ): void { + if (!raw || Array.isArray(raw) || raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) { + // ws delivers text as Buffer too; isBinary is handled at the event site. + } + const text = raw.toString(); + let message: ClientMessage; + try { + message = JSON.parse(text) as ClientMessage; + } catch { + send(peer, { type: "error", code: "bad-message", message: "Malformed JSON." }); + return; + } + const persisted = store.get(id); + if (!persisted) { + peer.socket.close(1008, "Unknown session"); + return; + } + + if (message.type === "join") { + if (peer.participant) return; + const role = + message.hostToken && persisted.hostToken && message.hostToken === persisted.hostToken + ? "host" + : "guest"; + peer.participant = { + clientId: randomUUID(), + displayName: sanitizeDisplayName(message.displayName), + color: sanitizeColor(message.color), + role, + }; + send(peer, { + type: "welcome", + clientId: peer.participant.clientId, + role, + mode: persisted.mode, + participants: participants(session).map(toWireParticipant), + snapshot: persisted.snapshot, + presence: Object.fromEntries(session.presence), + chat: persisted.chat, + rev: persisted.rev, + }); + broadcastParticipants(session, peer); + return; + } + + const participant = peer.participant; + if (!participant) { + send(peer, { + type: "error", + code: "bad-message", + message: "Send a join message first.", + }); + return; + } + + if (message.type === "snapshot") { + const authorization = authorizeSnapshot( + participant, + persisted.mode, + ENCODER.encode(text).length, + maxSnapshotBytes, + ); + if (!authorization.ok) { + send(peer, { + type: "error", + code: authorization.code, + message: authorization.message, + }); + return; + } + const project = preserveStoredComments(message.project ?? null, persisted.snapshot); + const rev = store.saveSnapshot(id, project); + broadcast(session, { type: "snapshot", project, origin: participant.clientId, rev }, peer); + return; + } + + if (message.type === "presence") { + const cursor = sanitizeCursor(message.cursor); + const view = sanitizeView(message.view); + session.presence.set(participant.clientId, { cursor, view }); + broadcast(session, { type: "presence", clientId: participant.clientId, cursor, view }, peer); + return; + } + + if (message.type === "set-mode") { + const authorization = authorizeHostAction(participant, "session mode"); + if (authorization) { + send(peer, { type: "error", code: "forbidden", message: authorization }); + return; + } + const mode = normalizeMode(message.mode); + store.saveMode(id, mode); + const list = participants(session); + if (clearParticipantOverrides(list)) broadcastParticipants(session); + broadcast(session, { type: "mode", mode }); + return; + } + + if (message.type === "set-participant-mode") { + const authorization = authorizeHostAction(participant, "participant permissions"); + if (authorization) { + send(peer, { type: "error", code: "forbidden", message: authorization }); + return; + } + if ( + typeof message.clientId === "string" && + setParticipantOverride( + participant, + participants(session), + message.clientId, + message.canEdit, + ) + ) { + broadcastParticipants(session); + } + return; + } + + if (message.type === "chat") { + const chatText = + typeof message.text === "string" ? message.text.trim().slice(0, MAX_CHAT_TEXT_LENGTH) : ""; + if (!chatText) return; + const now = Date.now(); + if ( + participant.lastChatTs !== undefined && + now - participant.lastChatTs < MIN_CHAT_INTERVAL_MS + ) + return; + participant.lastChatTs = now; + const chatMessage: CollabChatMessage = { + id: randomUUID(), + clientId: participant.clientId, + displayName: participant.displayName, + color: participant.color, + text: chatText, + coordinate: sanitizeCursor(message.coordinate), + ts: now, + }; + let chat = [...persisted.chat, chatMessage].slice(-CHAT_HISTORY_LIMIT); + while ( + chat.length > 1 && + ENCODER.encode(JSON.stringify(chat)).length > MAX_CHAT_STORAGE_BYTES + ) + chat = chat.slice(1); + store.saveChat(id, chat); + broadcast(session, { type: "chat", message: chatMessage }); + return; + } + + if (message.type === "comment-mutation") { + if (!participantCanEdit(participant, persisted.mode)) { + send(peer, { + type: "error", + code: "forbidden", + message: "You are in view-only mode and cannot comment.", + }); + return; + } + const action = message.action; + if (!action || typeof action !== "object") { + send(peer, { + type: "error", + code: "bad-message", + message: "Missing or invalid comment-mutation action.", + }); + return; + } + let sanitized: typeof action; + if (action.type === "add") { + const comment = validateComment(action.comment); + if (!comment) { + send(peer, { type: "error", code: "bad-message", message: "Invalid comment payload." }); + return; + } + sanitized = { type: "add", comment }; + } else if (action.type === "reply") { + const reply = validateReply(action.reply); + if (!isBoundedId(action.commentId) || !reply) { + send(peer, { type: "error", code: "bad-message", message: "Invalid reply payload." }); + return; + } + sanitized = { type: "reply", commentId: action.commentId, reply }; + } else if (action.type === "toggle-resolve" || action.type === "delete") { + if (!isBoundedId(action.commentId)) { + send(peer, { type: "error", code: "bad-message", message: "Invalid comment target." }); + return; + } + sanitized = + action.type === "delete" + ? { type: "delete", commentId: action.commentId } + : { + type: "toggle-resolve", + commentId: action.commentId, + ...(action.resolved !== undefined ? { resolved: action.resolved === true } : {}), + }; + } else { + send(peer, { + type: "error", + code: "bad-message", + message: "Unsupported comment-mutation action type.", + }); + return; + } + const now = Date.now(); + if ( + participant.lastCommentTs !== undefined && + now - participant.lastCommentTs < MIN_COMMENT_INTERVAL_MS + ) + return; + participant.lastCommentTs = now; + const project = + persisted.snapshot && + typeof persisted.snapshot === "object" && + !Array.isArray(persisted.snapshot) + ? { ...(persisted.snapshot as Record) } + : {}; + const comments = Array.isArray(project.comments) + ? (project.comments as Record[]) + : []; + if (sanitized.type === "add") { + const comment = sanitized.comment as Record; + if ( + !comments.some((existing) => existing.id === comment.id) && + comments.length >= MAX_COMMENTS_PER_SESSION + ) { + send(peer, { + type: "error", + code: "bad-message", + message: "Comment limit reached for this session.", + }); + return; + } + project.comments = comments.some((existing) => existing.id === comment.id) + ? comments + : [...comments, comment]; + } else if (sanitized.type === "reply") { + const target = comments.find((comment) => comment.id === sanitized.commentId); + if (!target) { + send(peer, { type: "error", code: "bad-message", message: "Invalid reply target." }); + return; + } + const replies = Array.isArray(target.replies) + ? (target.replies as Record[]) + : []; + if (replies.length >= MAX_REPLIES_PER_COMMENT) { + send(peer, { + type: "error", + code: "bad-message", + message: "Reply limit reached for this comment.", + }); + return; + } + const reply = sanitized.reply as Record; + project.comments = comments.map((comment) => + comment.id !== sanitized.commentId || replies.some((existing) => existing.id === reply.id) + ? comment + : { ...comment, replies: [...replies, reply] }, + ); + } else if (sanitized.type === "toggle-resolve") { + project.comments = comments.map((comment) => + comment.id === sanitized.commentId + ? { + ...comment, + resolved: sanitized.resolved !== undefined ? sanitized.resolved : !comment.resolved, + } + : comment, + ); + } else { + project.comments = comments.filter((comment) => comment.id !== sanitized.commentId); + } + store.saveProjectState(id, project); + broadcast(session, { type: "comment-mutation", action: sanitized }, peer); + } + } + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (request.method === "OPTIONS") return json(response, 204, null); + if ((url.pathname === "/" || url.pathname === "/health") && request.method === "GET") + return json(response, 200, { ok: true, service: "geolibre-collab" }); + if (url.pathname === "/sessions" && request.method === "POST") { + let raw = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + if (raw.length < 16_384) raw += chunk; + }); + request.on("end", () => { + let requested: unknown = {}; + try { + requested = raw ? JSON.parse(raw) : {}; + } catch { + // Match the Worker: malformed/empty input uses defaults. + } + const mode = normalizeMode((requested as { mode?: CollaborationMode } | null)?.mode); + for (let attempt = 0; attempt < 5; attempt++) { + const sessionId = randomCode(); + const hostToken = randomToken(); + if (store.create(sessionId, hostToken, mode)) + return json(response, 200, { sessionId, hostToken, mode }); + } + return json(response, 503, { + error: "Could not allocate a session code. Please try again.", + }); + }); + return; + } + json(response, 404, { error: "Not found" }); + }); + + server.on("upgrade", (request: IncomingMessage, socket, head) => { + const url = new URL(request.url ?? "/", "http://localhost"); + const match = url.pathname.match(/^\/sessions\/([^/]+)\/ws$/); + if (request.method !== "GET" || !match || !store.get(match[1])) { + socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + const id = match[1]; + wss.handleUpgrade(request, socket, head, (websocket) => { + const session = live(id); + const peer: Peer = { socket: websocket }; + session.peers.add(peer); + websocket.on("message", (raw, isBinary) => { + if (isBinary) { + send(peer, { + type: "error", + code: "bad-message", + message: "Binary frames are not supported.", + }); + return; + } + handleMessage(id, session, peer, raw); + }); + websocket.on("close", () => closePeer(id, session, peer)); + websocket.on("error", () => closePeer(id, session, peer)); + }); + }); + + return { + server, + store, + close: async () => { + for (const session of sessions.values()) { + if (session.cleanup) clearTimeout(session.cleanup); + for (const peer of session.peers) peer.socket.close(1001, "Server shutting down"); + } + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + wss.close(); + store.close(); + }, + }; +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const relay = createRelay(); + const port = positive(process.env.PORT, 8787); + relay.server.listen(port, process.env.HOST ?? "0.0.0.0", () => { + console.log(`GeoLibre collaboration relay listening on port ${port}`); + }); +} diff --git a/workers/collab-node/src/store.ts b/workers/collab-node/src/store.ts new file mode 100644 index 0000000000..a60a604760 --- /dev/null +++ b/workers/collab-node/src/store.ts @@ -0,0 +1,122 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import type { CollabChatMessage, CollaborationMode } from "@geolibre/collab-core"; + +export interface StoredSession { + id: string; + hostToken: string; + mode: CollaborationMode; + rev: number; + snapshot: unknown | null; + chat: CollabChatMessage[]; + updatedAt: number; +} + +interface SessionRow { + id: string; + host_token: string; + mode: string; + rev: number; + snapshot: string | null; + chat: string; + updated_at: number; +} + +function parseJson(raw: string | null, fallback: T): T { + if (raw === null) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export class SessionStore { + private readonly db: DatabaseSync; + + constructor(path: string) { + if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); + this.db = new DatabaseSync(path); + this.db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS collab_sessions ( + id TEXT PRIMARY KEY, + host_token TEXT NOT NULL, + mode TEXT NOT NULL, + rev INTEGER NOT NULL DEFAULT 0, + snapshot TEXT, + chat TEXT NOT NULL DEFAULT '[]', + updated_at INTEGER NOT NULL + ); + `); + } + + create(id: string, hostToken: string, mode: CollaborationMode): boolean { + const result = this.db + .prepare( + `INSERT OR IGNORE INTO collab_sessions + (id, host_token, mode, rev, snapshot, chat, updated_at) + VALUES (?, ?, ?, 0, NULL, '[]', ?)`, + ) + .run(id, hostToken, mode, Date.now()); + return result.changes === 1; + } + + get(id: string): StoredSession | null { + const row = this.db.prepare("SELECT * FROM collab_sessions WHERE id = ?").get(id) as unknown as + | SessionRow + | undefined; + if (!row) return null; + return { + id: row.id, + hostToken: row.host_token, + mode: row.mode === "view-only" ? "view-only" : "co-edit", + rev: row.rev, + snapshot: parseJson(row.snapshot, null), + chat: parseJson(row.chat, []), + updatedAt: row.updated_at, + }; + } + + saveSnapshot(id: string, project: unknown): number { + this.db + .prepare( + `UPDATE collab_sessions + SET snapshot = ?, rev = rev + 1, updated_at = ? + WHERE id = ?`, + ) + .run(JSON.stringify(project), Date.now(), id); + return ( + this.db.prepare("SELECT rev FROM collab_sessions WHERE id = ?").get(id) as { + rev: number; + } + ).rev; + } + + saveProjectState(id: string, project: unknown): void { + this.db + .prepare("UPDATE collab_sessions SET snapshot = ?, updated_at = ? WHERE id = ?") + .run(JSON.stringify(project), Date.now(), id); + } + + saveMode(id: string, mode: CollaborationMode): void { + this.db + .prepare("UPDATE collab_sessions SET mode = ?, updated_at = ? WHERE id = ?") + .run(mode, Date.now(), id); + } + + saveChat(id: string, chat: CollabChatMessage[]): void { + this.db + .prepare("UPDATE collab_sessions SET chat = ?, updated_at = ? WHERE id = ?") + .run(JSON.stringify(chat), Date.now(), id); + } + + delete(id: string): void { + this.db.prepare("DELETE FROM collab_sessions WHERE id = ?").run(id); + } + + close(): void { + this.db.close(); + } +} diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts new file mode 100644 index 0000000000..6ce1041550 --- /dev/null +++ b/workers/collab-node/test/relay.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, it } from "node:test"; +import { WebSocket } from "ws"; +import { createRelay } from "../src/server.js"; + +type Message = Record & { type: string }; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()?.(); +}); + +async function start(options: Parameters[0] = {}) { + const relay = createRelay({ dbPath: ":memory:", ...options }); + await new Promise((resolve) => relay.server.listen(0, "127.0.0.1", resolve)); + cleanups.push(relay.close); + const address = relay.server.address(); + assert(address && typeof address === "object"); + return { relay, http: `http://127.0.0.1:${address.port}` }; +} + +async function createSession(http: string, mode = "co-edit") { + const response = await fetch(`${http}/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode }), + }); + assert.equal(response.status, 200); + return (await response.json()) as { + sessionId: string; + hostToken: string; + mode: string; + }; +} + +async function connect(http: string, sessionId: string): Promise { + const socket = new WebSocket(`${http.replace("http", "ws")}/sessions/${sessionId}/ws`); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + return socket; +} + +function next(socket: WebSocket, type?: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${type}`)), 2000); + const receive = (raw: WebSocket.RawData) => { + const message = JSON.parse(raw.toString()) as Message; + if (type && message.type !== type) { + socket.once("message", receive); + return; + } + clearTimeout(timer); + resolve(message); + }; + socket.once("message", receive); + }); +} + +async function joinSession( + socket: WebSocket, + hostToken?: string, + displayName = "Participant", +): Promise { + socket.send( + JSON.stringify({ + type: "join", + clientId: "ignored-client-id", + displayName, + color: "#123456", + ...(hostToken ? { hostToken } : {}), + }), + ); + return next(socket, "welcome"); +} + +describe("Node collaboration relay", () => { + it("serves health, creates sessions, and rejects unknown websocket routes", async () => { + const { http } = await start(); + const health = await fetch(`${http}/health`); + assert.deepEqual(await health.json(), { ok: true, service: "geolibre-collab" }); + + const created = await createSession(http, "view-only"); + assert.match(created.sessionId, /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$/); + assert.match(created.hostToken, /^[0-9a-f]{48}$/); + assert.equal(created.mode, "view-only"); + + await assert.rejects(connect(http, "NOTFOUND"), /Unexpected server response: 404/); + }); + + it("enforces view-only mode and lets only the host change it", async () => { + const { http } = await start(); + const created = await createSession(http, "view-only"); + const host = await connect(http, created.sessionId); + const guest = await connect(http, created.sessionId); + await joinSession(host, created.hostToken, "Host"); + await joinSession(guest, undefined, "Guest"); + + guest.send(JSON.stringify({ type: "snapshot", project: { name: "blocked" }, rev: 0 })); + const forbidden = await next(guest, "error"); + assert.equal(forbidden.code, "forbidden"); + + guest.send(JSON.stringify({ type: "set-mode", mode: "co-edit" })); + assert.equal((await next(guest, "error")).code, "forbidden"); + + host.send(JSON.stringify({ type: "set-mode", mode: "co-edit" })); + assert.equal((await next(guest, "mode")).mode, "co-edit"); + + guest.send(JSON.stringify({ type: "snapshot", project: { name: "accepted" }, rev: 999 })); + const snapshot = await next(host, "snapshot"); + assert.deepEqual(snapshot.project, { name: "accepted" }); + assert.equal(snapshot.rev, 1); + host.close(); + guest.close(); + }); + + it("applies participant overrides and the configured snapshot cap", async () => { + const { http } = await start({ maxSnapshotBytes: 300 }); + const created = await createSession(http); + const host = await connect(http, created.sessionId); + const guest = await connect(http, created.sessionId); + await joinSession(host, created.hostToken); + const welcome = await joinSession(guest); + const guestId = welcome.clientId as string; + + host.send( + JSON.stringify({ + type: "set-participant-mode", + clientId: guestId, + canEdit: false, + }), + ); + await next(guest, "participants"); + guest.send(JSON.stringify({ type: "snapshot", project: {}, rev: 0 })); + assert.equal((await next(guest, "error")).code, "forbidden"); + + host.send( + JSON.stringify({ + type: "set-participant-mode", + clientId: guestId, + canEdit: true, + }), + ); + await next(guest, "participants"); + guest.send(JSON.stringify({ type: "snapshot", project: { data: "x".repeat(400) }, rev: 0 })); + assert.equal((await next(guest, "error")).code, "too-large"); + host.close(); + guest.close(); + }); + + it("restores the latest snapshot and revision from SQLite after restart", async () => { + const directory = await mkdtemp("/tmp/geolibre-collab-"); + const dbPath = join(directory, "relay.sqlite"); + cleanups.push(() => rm(directory, { recursive: true, force: true })); + + const first = await start({ dbPath, idleTtlMs: 60_000 }); + const created = await createSession(first.http); + const host = await connect(first.http, created.sessionId); + await joinSession(host, created.hostToken); + host.send(JSON.stringify({ type: "snapshot", project: { persisted: true }, rev: 0 })); + await new Promise((resolve) => setTimeout(resolve, 25)); + host.close(); + await cleanups.pop()?.(); + + const second = await start({ dbPath, idleTtlMs: 60_000 }); + const rejoined = await connect(second.http, created.sessionId); + const welcome = await joinSession(rejoined, created.hostToken); + assert.deepEqual(welcome.snapshot, { persisted: true }); + assert.equal(welcome.rev, 1); + rejoined.close(); + }); +}); diff --git a/workers/collab-node/tsconfig.json b/workers/collab-node/tsconfig.json new file mode 100644 index 0000000000..4a4adb78a6 --- /dev/null +++ b/workers/collab-node/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/workers/collab/package.json b/workers/collab/package.json index e713e3db36..987b82bce9 100644 --- a/workers/collab/package.json +++ b/workers/collab/package.json @@ -7,6 +7,9 @@ "dev": "wrangler dev", "deploy": "wrangler deploy" }, + "dependencies": { + "@geolibre/collab-core": "0.0.0" + }, "devDependencies": { "@cloudflare/workers-types": "^5.20260728.1", "wrangler": "^4.114.0" diff --git a/workers/collab/src/comment-validate.ts b/workers/collab/src/comment-validate.ts index b57cb555ad..838561453b 100644 --- a/workers/collab/src/comment-validate.ts +++ b/workers/collab/src/comment-validate.ts @@ -1,203 +1 @@ -// Pure validators for comment-mutation payloads. These mirror the -// `ProjectComment` / `CommentReply` shapes from `@geolibre/core` but operate on -// untrusted `unknown` input, returning a sanitized object or `null`. - -/** Body length cap — matches the chat limit so comments can't store unbounded text. */ -export const MAX_COMMENT_BODY_LENGTH = 2000; - -/** Author name length cap — generous for display names but bounded. */ -export const MAX_COMMENT_AUTHOR_LENGTH = 120; - -/** Identifier length cap for comment/reply ids, layerId, and string featureId. */ -export const MAX_ID_LENGTH = 200; - -/** Minimum gap between a socket's comment-mutation frames (ms). */ -export const MIN_COMMENT_INTERVAL_MS = 250; - -/** Maximum number of replies stored per comment. */ -export const MAX_REPLIES_PER_COMMENT = 100; - -/** Maximum number of comments stored per session. Bounds the snapshot growth a - * sustained stream of "add" mutations can cause, the way `CHAT_HISTORY_LIMIT` - * bounds the chat log. */ -export const MAX_COMMENTS_PER_SESSION = 500; - -/** True when `value` is a non-empty string within {@link MAX_ID_LENGTH}. */ -export function isBoundedId(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH; -} - -/** Carry the stored `comments` list into an incoming full-project snapshot that - * doesn't supply one of its own. - * - * The relay writes comments straight into the stored snapshot (see - * `handleCommentMutation`), but `serializeProject` omits the key entirely when - * a peer holds none — so a peer that hasn't merged those broadcasts yet (a race - * with its debounced snapshot, or a client that joined before them) would - * otherwise replace the persisted comments with nothing. A project that carries - * its own `comments` still wins, so a delete is never resurrected. - * - * `stored` is the already-parsed stored snapshot, or `null` when absent/corrupt. - */ -export function preserveStoredComments(project: unknown, stored: unknown): unknown { - if (!project || typeof project !== "object" || Array.isArray(project)) return project; - if ("comments" in project) return project; - if (!stored || typeof stored !== "object" || Array.isArray(stored)) return project; - const comments = (stored as Record).comments; - if (!Array.isArray(comments) || comments.length === 0) return project; - return { ...(project as Record), comments }; -} - -const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - -function finite(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n); -} - -// -- anchor ------------------------------------------------------------------- - -interface PointAnchor { - type: "point"; - lngLat: [number, number]; -} - -interface FeatureAnchor { - type: "feature"; - layerId: string; - featureId: string | number; - lngLat?: [number, number]; -} - -export type ValidatedAnchor = PointAnchor | FeatureAnchor; - -export function validateAnchor(raw: unknown): ValidatedAnchor | null { - if (!raw || typeof raw !== "object") return null; - const o = raw as Record; - - if (o.type === "point") { - if (!Array.isArray(o.lngLat) || o.lngLat.length !== 2) return null; - const [lng, lat] = o.lngLat; - if (!finite(lng) || !finite(lat)) return null; - return { type: "point", lngLat: [lng, lat] }; - } - - if (o.type === "feature") { - if (!isBoundedId(o.layerId)) return null; - if (typeof o.featureId !== "string" && typeof o.featureId !== "number") return null; - if (typeof o.featureId === "string" && !isBoundedId(o.featureId)) return null; - if (typeof o.featureId === "number" && !finite(o.featureId)) return null; - const anchor: FeatureAnchor = { - type: "feature", - layerId: o.layerId, - featureId: o.featureId, - }; - if (Array.isArray(o.lngLat) && o.lngLat.length === 2) { - const [lng, lat] = o.lngLat; - if (finite(lng) && finite(lat)) { - anchor.lngLat = [lng, lat]; - } - } - return anchor; - } - - return null; -} - -// -- author ------------------------------------------------------------------- - -export interface ValidatedAuthor { - name: string; - color: string; -} - -export function validateAuthor(raw: unknown): ValidatedAuthor | null { - if (!raw || typeof raw !== "object") return null; - const o = raw as Record; - if (typeof o.name !== "string") return null; - const name = o.name.trim().slice(0, MAX_COMMENT_AUTHOR_LENGTH); - if (!name) return null; - if (typeof o.color !== "string" || !HEX_COLOR_RE.test(o.color)) return null; - return { name, color: o.color }; -} - -// -- comment ------------------------------------------------------------------ - -export interface ValidatedComment { - id: string; - anchor: ValidatedAnchor; - author: ValidatedAuthor; - body: string; - createdAt: string; - resolved: boolean; - replies: ValidatedReply[]; -} - -export function validateComment(raw: unknown): ValidatedComment | null { - if (!raw || typeof raw !== "object") return null; - const o = raw as Record; - - if (!isBoundedId(o.id)) return null; - - const anchor = validateAnchor(o.anchor); - if (!anchor) return null; - - const author = validateAuthor(o.author); - if (!author) return null; - - if (typeof o.body !== "string") return null; - const body = o.body.slice(0, MAX_COMMENT_BODY_LENGTH); - if (!body.trim()) return null; - - const createdAt = - typeof o.createdAt === "string" && !Number.isNaN(Date.parse(o.createdAt)) - ? o.createdAt - : new Date().toISOString(); - - const replies: ValidatedReply[] = []; - if (Array.isArray(o.replies)) { - for (const r of o.replies.slice(0, MAX_REPLIES_PER_COMMENT)) { - const validated = validateReply(r); - if (validated) replies.push(validated); - } - } - - return { - id: o.id, - anchor, - author, - body, - createdAt, - resolved: Boolean(o.resolved), - replies, - }; -} - -// -- reply -------------------------------------------------------------------- - -export interface ValidatedReply { - id: string; - author: ValidatedAuthor; - body: string; - createdAt: string; -} - -export function validateReply(raw: unknown): ValidatedReply | null { - if (!raw || typeof raw !== "object") return null; - const o = raw as Record; - - if (!isBoundedId(o.id)) return null; - - const author = validateAuthor(o.author); - if (!author) return null; - - if (typeof o.body !== "string") return null; - const body = o.body.slice(0, MAX_COMMENT_BODY_LENGTH); - if (!body.trim()) return null; - - const createdAt = - typeof o.createdAt === "string" && !Number.isNaN(Date.parse(o.createdAt)) - ? o.createdAt - : new Date().toISOString(); - - return { id: o.id, author, body, createdAt }; -} +export * from "@geolibre/collab-core/comment-validate"; diff --git a/workers/collab/src/protocol.ts b/workers/collab/src/protocol.ts index f024bf5b63..82048fe02b 100644 --- a/workers/collab/src/protocol.ts +++ b/workers/collab/src/protocol.ts @@ -1,183 +1 @@ -// Wire protocol for the live-collaboration relay. -// -// This is the worker-side copy. The frontend keeps a parallel copy in -// `apps/geolibre-desktop/src/lib/collab-protocol.ts` with the `project` field -// typed as the concrete `GeoLibreProject`. The relay never inspects a project's -// contents — it only stores and forwards the opaque JSON — so here `project` is -// `unknown`. Keep the two `type` discriminants and field names in sync. - -export type CollaborationRole = "host" | "guest"; -export type CollaborationMode = "view-only" | "co-edit"; - -export interface CollabParticipant { - clientId: string; - displayName: string; - color: string; - role: CollaborationRole; - /** - * Host-set per-participant edit override (#754, Part 3). `null` means "follow - * the session mode"; `true`/`false` pins this participant to can-edit / - * view-only regardless of the session default. Always `null` for the host - * (the host can always edit). - */ - editOverride: boolean | null; -} - -export interface CollabCursor { - lng: number; - lat: number; -} - -/** One in-session chat message (#754, Part 4). Ephemeral session state. */ -export interface CollabChatMessage { - /** Server-assigned id (dedupes optimistic local rendering / React keys). */ - id: string; - /** clientId of the author. */ - clientId: string; - displayName: string; - color: string; - text: string; - /** Optional map coordinate the author attached; clickable in peers' UIs. */ - coordinate?: CollabCursor | null; - /** Server-assigned epoch-ms timestamp. */ - ts: number; -} - -export interface CollabView { - center: [number, number]; - zoom: number; - bearing: number; - pitch: number; - bbox?: [number, number, number, number]; -} - -// Client -> server ----------------------------------------------------------- - -export interface JoinMessage { - type: "join"; - clientId: string; - displayName: string; - color: string; - /** Presented by the session creator to claim the host role. */ - hostToken?: string; -} - -export interface ClientSnapshotMessage { - type: "snapshot"; - project: unknown; - rev: number; -} - -export interface ClientPresenceMessage { - type: "presence"; - cursor?: CollabCursor | null; - view?: CollabView | null; -} - -export interface SetModeMessage { - type: "set-mode"; - mode: CollaborationMode; -} - -/** Host-only: pin one participant to can-edit / view-only (#754, Part 3). */ -export interface SetParticipantModeMessage { - type: "set-participant-mode"; - clientId: string; - canEdit: boolean; -} - -/** Send a chat message to the session (#754, Part 4). */ -export interface ChatSendMessage { - type: "chat"; - text: string; - coordinate?: CollabCursor | null; -} - -export type CommentMutationAction = - | { type: "add"; comment: unknown } - | { type: "reply"; commentId: string; reply: unknown } - | { type: "toggle-resolve"; commentId: string; resolved?: boolean } - | { type: "delete"; commentId: string }; - -export interface CommentMutationMessage { - type: "comment-mutation"; - action: CommentMutationAction; -} - -export type ClientMessage = - | JoinMessage - | ClientSnapshotMessage - | ClientPresenceMessage - | SetModeMessage - | SetParticipantModeMessage - | ChatSendMessage - | CommentMutationMessage; - -// Server -> client ----------------------------------------------------------- - -export interface WelcomeMessage { - type: "welcome"; - clientId: string; - role: CollaborationRole; - mode: CollaborationMode; - participants: CollabParticipant[]; - snapshot: unknown | null; - /** Current presence of existing participants (keyed by clientId) so a late - * joiner sees their cursors/viewports without waiting for the next move. */ - presence: Record; - /** Recent chat history so a late joiner sees the conversation so far (#754). */ - chat: CollabChatMessage[]; - rev: number; -} - -export interface PresenceEntry { - cursor: CollabCursor | null; - view: CollabView | null; -} - -export interface ServerSnapshotMessage { - type: "snapshot"; - project: unknown; - origin: string; - rev: number; -} - -export interface ServerPresenceMessage { - type: "presence"; - clientId: string; - cursor?: CollabCursor | null; - view?: CollabView | null; -} - -export interface ParticipantsMessage { - type: "participants"; - participants: CollabParticipant[]; -} - -export interface ModeMessage { - type: "mode"; - mode: CollaborationMode; -} - -/** Fan-out of a chat message to every participant (including the sender, so the - * server's ordering is authoritative). */ -export interface ChatBroadcastMessage { - type: "chat"; - message: CollabChatMessage; -} - -export interface ErrorMessage { - type: "error"; - code: "forbidden" | "too-large" | "bad-message" | "not-found"; - message: string; -} - -export type ServerMessage = - | WelcomeMessage - | ServerSnapshotMessage - | ServerPresenceMessage - | ParticipantsMessage - | ModeMessage - | ChatBroadcastMessage - | CommentMutationMessage - | ErrorMessage; +export * from "@geolibre/collab-core/protocol"; diff --git a/workers/collab/src/session.ts b/workers/collab/src/session.ts index 7b9f637f92..ddc5c7d214 100644 --- a/workers/collab/src/session.ts +++ b/workers/collab/src/session.ts @@ -1,9 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import type { CollabChatMessage, - CollabCursor, CollabParticipant, - CollabView, ClientMessage, CollaborationMode, CollaborationRole, @@ -19,6 +17,26 @@ import { validateComment, validateReply, } from "./comment-validate"; +import { + authorizeHostAction, + authorizeSnapshot, + CHAT_HISTORY_LIMIT, + clearParticipantOverrides, + EMPTY_SESSION_TTL_MS, + MAX_CHAT_STORAGE_BYTES, + MAX_CHAT_TEXT_LENGTH, + MAX_SNAPSHOT_BYTES, + MIN_CHAT_INTERVAL_MS, + normalizeMode, + participantCanEdit, + sanitizeColor, + sanitizeCursor, + sanitizeDisplayName, + sanitizeView, + setParticipantOverride, + toWireParticipant, + type SessionParticipant, +} from "@geolibre/collab-core"; function finite(n: unknown): n is number { return typeof n === "number" && Number.isFinite(n); @@ -28,38 +46,6 @@ function finite(n: unknown): n is number { // the stored-chat validator so both enforce the same shape. const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; -/** Accept a cursor only when both coordinates are finite numbers, so a crafted - * frame can't push NaN/strings into peers' `marker.setLngLat`. */ -function sanitizeCursor(c: unknown): CollabCursor | null { - if (c && typeof c === "object") { - const { lng, lat } = c as Record; - if (finite(lng) && finite(lat)) return { lng, lat }; - } - return null; -} - -/** Accept a view only with a finite center; coerce the rest and keep bbox only - * when it is a finite 4-tuple. Drops any hostile extra fields. */ -function sanitizeView(v: unknown): CollabView | null { - if (!v || typeof v !== "object") return null; - const o = v as Record; - const center = o.center; - if (!Array.isArray(center) || !finite(center[0]) || !finite(center[1])) { - return null; - } - const view: CollabView = { - center: [center[0], center[1]], - zoom: finite(o.zoom) ? o.zoom : 0, - bearing: finite(o.bearing) ? o.bearing : 0, - pitch: finite(o.pitch) ? o.pitch : 0, - }; - const bbox = o.bbox; - if (Array.isArray(bbox) && bbox.length === 4 && bbox.every((n) => finite(n))) { - view.bbox = [bbox[0], bbox[1], bbox[2], bbox[3]]; - } - return view; -} - /** Parse the stored snapshot defensively: a corrupt value yields null rather * than throwing (which would lock joiners out of the session). */ function parseStoredSnapshot(snapshot: string | undefined): unknown { @@ -78,31 +64,23 @@ export interface Env { // Cloudflare caps a single WebSocket message at ~1 MiB. Reject project // snapshots above this so one oversized embedded FeatureCollection can't blow // the actor; the client surfaces a "share via URL instead" hint. -const MAX_SNAPSHOT_BYTES = 1_000_000; - // Reclaim an empty session's storage this long after the last socket closes, so // abandoned codes don't accumulate. A rejoin before the alarm fires cancels it. -const EMPTY_SESSION_TTL_MS = 2 * 60 * 60 * 1000; - // Cap a single chat message so one frame can't store an unbounded string. -const MAX_CHAT_TEXT_LENGTH = 2000; // How many recent chat messages to retain so a late joiner sees recent history. // Persisted (not in-memory) so it survives a hibernation between messages. -const CHAT_HISTORY_LIMIT = 50; // Hard byte budget for the persisted chat log, comfortably under Cloudflare's // ~128 KiB per-value storage cap (multi-byte text can blow the count limit). -const MAX_CHAT_STORAGE_BYTES = 100_000; // Minimum gap between a socket's chat frames. Each chat costs a storage // read+write and a fan-out, so silently drop bursts faster than this floor to // keep one client from exhausting the session's storage-op budget. Generous // enough that normal typing/sending is never affected. -const MIN_CHAT_INTERVAL_MS = 250; // Stateless and reused across frames (snapshots can arrive several times a // second), so we don't allocate a new encoder per message. const ENCODER = new TextEncoder(); -interface SocketAttachment { +interface SocketAttachment extends SessionParticipant { clientId: string; displayName: string; color: string; @@ -120,14 +98,6 @@ interface SocketAttachment { lastCommentTs?: number; } -/** Effective edit permission: the host always edits; otherwise a host-set - * override wins, falling back to the session mode. */ -function canEdit(attachment: SocketAttachment, mode: CollaborationMode): boolean { - if (attachment.role === "host") return true; - if (attachment.editOverride !== undefined) return attachment.editOverride; - return mode === "co-edit"; -} - /** Validate a single stored chat entry's field types so a corrupt record can't * reach clients (where it would, e.g., crash `coordinate.lat.toFixed`). */ function isValidChatMessage(m: unknown): m is CollabChatMessage { @@ -358,17 +328,12 @@ export class CollabSession extends DurableObject { clientId: crypto.randomUUID(), // Guard against a non-string displayName (JSON.parse won't enforce the // type) so a crafted frame can't crash the handler on `.slice`. - displayName: - (typeof message.displayName === "string" ? message.displayName : "").slice(0, 60) || - "Guest", + displayName: sanitizeDisplayName(message.displayName), // Only accept a hex color; fall back to neutral grey so a hostile value // never reaches peers (defense-in-depth with the client's DOM rendering). // Guard the type first: `.test()` coerces a non-string (number/array) to a // string, which could spuriously pass and store a non-string color. - color: - typeof message.color === "string" && HEX_COLOR_RE.test(message.color) - ? message.color - : "#888888", + color: sanitizeColor(message.color), role, }; ws.serializeAttachment(attachment); @@ -405,22 +370,12 @@ export class CollabSession extends DurableObject { // A host-set per-participant override takes precedence over the session // default, so a single guest can be pinned to view-only (or granted edit) in // an otherwise co-edit (or view-only) session (#754, Part 3). - if (!canEdit(attachment, mode)) { + const decision = authorizeSnapshot(attachment, mode, byteLength); + if (!decision.ok) { this.send(ws, { type: "error", - code: "forbidden", - message: - attachment.editOverride === false - ? "The host has set you to view-only." - : "This session is view-only.", - }); - return; - } - if (byteLength > MAX_SNAPSHOT_BYTES) { - this.send(ws, { - type: "error", - code: "too-large", - message: "Project is too large to sync live. Share it via URL instead.", + code: decision.code, + message: decision.message, }); return; } @@ -478,28 +433,38 @@ export class CollabSession extends DurableObject { attachment: SocketAttachment, mode: CollaborationMode, ): Promise { - if (attachment.role !== "host") { + const forbidden = authorizeHostAction(attachment, "session mode"); + if (forbidden) { this.send(ws, { type: "error", code: "forbidden", - message: "Only the host can change the session mode.", + message: forbidden, }); return; } - const next: CollaborationMode = mode === "view-only" ? "view-only" : "co-edit"; + const next = normalizeMode(mode); await this.ctx.storage.put("mode", next); // A session-wide mode change is authoritative: clear any per-participant // overrides so the new mode applies to everyone. Without this, a guest the // host previously pinned to can-edit would keep editing through a later // switch to view-only (a "sticky override" footgun), and there is otherwise // no path to reset an override back to "follow the session mode". - let clearedAny = false; - for (const socket of this.ctx.getWebSockets()) { - const a = socket.deserializeAttachment() as SocketAttachment | null; - if (a && a.editOverride !== undefined) { - a.editOverride = undefined; - socket.serializeAttachment(a); - clearedAny = true; + const socketsWithAttachments = this.ctx + .getWebSockets() + .map((socket) => ({ + socket, + attachment: socket.deserializeAttachment() as SocketAttachment | null, + })) + .filter( + (entry): entry is { socket: WebSocket; attachment: SocketAttachment } => + entry.attachment !== null, + ); + const clearedAny = clearParticipantOverrides( + socketsWithAttachments.map((entry) => entry.attachment), + ); + if (clearedAny) { + for (const { socket, attachment } of socketsWithAttachments) { + socket.serializeAttachment(attachment); } } // Broadcast the cleared roster first, then the new mode, so clients have @@ -514,32 +479,42 @@ export class CollabSession extends DurableObject { attachment: SocketAttachment, message: Extract, ): void { - if (attachment.role !== "host") { + const forbidden = authorizeHostAction(attachment, "participant permissions"); + if (forbidden) { this.send(ws, { type: "error", code: "forbidden", - message: "Only the host can change participant permissions.", + message: forbidden, }); return; } // `message` is untrusted JSON, so guard the lookup key's type (mirrors the // strict-boolean coercion below) before matching it against attachments. - if (typeof message.clientId !== "string") return; - // Find the addressed participant's socket and pin its override. The host - // (and any other host socket) is always an editor, so refuse to override one - // — that keeps `editOverride` meaningful only for guests. - const target = this.socketByClientId(message.clientId); + const socketsWithAttachments = this.ctx + .getWebSockets() + .map((socket) => ({ + socket, + attachment: socket.deserializeAttachment() as SocketAttachment | null, + })) + .filter( + (entry): entry is { socket: WebSocket; attachment: SocketAttachment } => + entry.attachment !== null, + ); + const changed = setParticipantOverride( + attachment, + socketsWithAttachments.map((entry) => entry.attachment), + message.clientId, + message.canEdit, + ); + if (!changed) return; + const target = socketsWithAttachments.find( + (entry) => entry.attachment.clientId === message.clientId, + ); // Target disconnected between the host's click and this frame: the // disconnect already broadcasts an updated roster, so the host's view (and // the now-absent toggle) reconciles on its own; no error frame needed. if (!target) return; - const targetAttachment = target.deserializeAttachment() as SocketAttachment | null; - if (!targetAttachment || targetAttachment.role === "host") return; - // Coerce to a strict boolean: `message` is untrusted JSON (the static type - // is erased at runtime), so a crafted `"canEdit": 1` must not store a - // non-boolean on the attachment. - targetAttachment.editOverride = message.canEdit === true; - target.serializeAttachment(targetAttachment); + target.socket.serializeAttachment(target.attachment); // Everyone re-derives effective permission from the participants list (the // affected guest learns its own change here too), so a single broadcast // suffices. @@ -622,15 +597,7 @@ export class CollabSession extends DurableObject { if (socket === except) continue; const a = socket.deserializeAttachment() as SocketAttachment | null; if (a) { - result.push({ - clientId: a.clientId, - displayName: a.displayName, - color: a.color, - role: a.role, - // Normalize the attachment's `undefined` (follow session mode) to the - // wire's `null`; the host is always an editor with no override. - editOverride: a.role === "host" ? null : (a.editOverride ?? null), - }); + result.push(toWireParticipant(a)); } } return result; @@ -757,7 +724,7 @@ export class CollabSession extends DurableObject { } const mode = (await this.ctx.storage.get("mode")) ?? "co-edit"; - if (!canEdit(attachment, mode)) { + if (!participantCanEdit(attachment, mode)) { this.send(ws, { type: "error", code: "forbidden", From 40008ab439c6da83244f22dda5f1d181f03bac6f Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 22:16:45 -0400 Subject: [PATCH 02/11] fix(server): unbreak the Docker stack found by an end-to-end run Three defects only a real `docker compose up` surfaces. The entrypoint embeds its runtime-config program in a single-quoted `python -c`, so the apostrophes in `'/'.join(...)` closed that argument and every web container died at boot on a SyntaxError. The server image created only /data, so a named volume mounted at /data/objects came up root-owned and the unprivileged user could not write any project. Forking required a request body despite documenting a private default, so a bodyless "fork this" answered 422. --- backend/geolibre_server_api/Dockerfile | 8 +++++++- backend/geolibre_server_api/geolibre_server_api/main.py | 5 ++++- backend/geolibre_server_api/tests/test_api.py | 7 +++++++ docker/entrypoint.sh | 8 +++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/backend/geolibre_server_api/Dockerfile b/backend/geolibre_server_api/Dockerfile index 14c27c49c7..1ccb193fe0 100644 --- a/backend/geolibre_server_api/Dockerfile +++ b/backend/geolibre_server_api/Dockerfile @@ -5,7 +5,13 @@ COPY pyproject.toml README.md ./ COPY geolibre_server_api ./geolibre_server_api RUN pip install --no-cache-dir ".[postgres,s3]" -RUN useradd --create-home geolibre && mkdir -p /data && chown geolibre:geolibre /data +# Create the storage directory in the image, not just /data. A named volume +# mounted at /data/objects inherits the image's ownership only when that exact +# path already exists; otherwise Docker creates the mountpoint as root and the +# unprivileged user below cannot write projects into it. +RUN useradd --create-home geolibre \ + && mkdir -p /data/objects \ + && chown -R geolibre:geolibre /data USER geolibre ENV GEOLIBRE_DATABASE_URL=sqlite:////data/geolibre-server-api.db \ diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index e007e192bd..5f886affd8 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -584,7 +584,10 @@ def delete_project_route( @app.post("/api/projects/{project_id}/forks", status_code=201) def fork_project( project_id: str, - body: ForkRequest, + # Defaulted so the body may be omitted entirely: "fork this project" with + # no options is the common call, and every field already has a default. + # Without this FastAPI treats the body as required and answers 422. + body: ForkRequest = ForkRequest(), account: Account = Depends(required_account), session: Session = Depends(db), ): diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py index c7cc127bdf..9c35e8742e 100644 --- a/backend/geolibre_server_api/tests/test_api.py +++ b/backend/geolibre_server_api/tests/test_api.py @@ -144,6 +144,13 @@ def test_thumbnail_fork_and_slug_collision(client): assert forked.status_code == 201 assert forked.json()["project"]["username"] == "grace" assert client.get(f"/api/projects/{project['id']}").json()["project"]["forkCount"] == 1 + + # Forking with no body at all is the common "fork this project" call, and the + # documented default is private. Sending a body here would not exercise it. + bodyless = client.post(f"/api/projects/{project['id']}/forks", headers=auth(recipient)) + assert bodyless.status_code == 201 + assert bodyless.json()["project"]["visibility"] == "private" + assert client.get(f"/api/projects/{project['id']}").json()["project"]["forkCount"] == 2 assert client.delete(path, headers=auth(owner)).status_code == 204 assert client.get(path).status_code == 404 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 5c2027da2f..41fc0dfe8c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -200,9 +200,15 @@ def service_url(name, value, schemes, loopback_schemes, loopback_hosts): if parsed.scheme in loopback_schemes and parsed.hostname in loopback_hosts: return value if parsed.scheme not in schemes or not parsed.netloc: + # Joined outside the f-string, and with double quotes. This whole program + # is one single-quoted `python -c` argument, so a literal apostrophe here + # would close that argument in the shell; Python would then receive + # `{/.join(...)}` and die of a SyntaxError. Note -c compiles as a unit, so + # that failure hits every deployment at boot, not just an invalid URL. + allowed_hosts = "/".join(loopback_hosts) raise SystemExit( f"ERROR: {name} must be a {schemes[0]}:// URL " - f"(or {loopback_schemes[0]}:// on {'/'.join(loopback_hosts)}), not {value!r}." + f"(or {loopback_schemes[0]}:// on {allowed_hosts}), not {value!r}." ) return value From 7d1c7c6f224e2fea0e130b13f5ab446bd066c2f3 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 22:42:57 -0400 Subject: [PATCH 03/11] Address Copilot, Claude, and CodeRabbit review feedback Relay, crash and correctness: - Reject non-object JSON before reading .type. `null` is valid JSON, so one frame from any client threw inside the ws listener and killed the process, taking every other session with it. Reproduced against the container, which Docker then restarted. handleMessage is also wrapped defensively. - Answer presence frames before the per-message SQLite read. It is a synchronous SELECT * over the snapshot and chat blobs, and this relay hosts every session in one process, so a cursor burst added latency to unrelated sessions. - Add an error listener to the session-create request stream and destroy the request past the size cap, rather than reading on forever. - Sweep sessions that were created but never joined; only the socket-close path deleted rows, so an unjoined code lived in SQLite forever. - Add a ping/pong heartbeat, so a connection dropped without a close frame stops pinning the roster and blocking idle cleanup, and terminate sockets that never finish the closing handshake instead of hanging shutdown. - Make saveSnapshot atomic with UPDATE ... RETURNING rev. - Trim in sanitizeDisplayName: a whitespace-only name is truthy and reached the roster, participant broadcasts, and chat author fields. Server correctness and hardening: - USERNAME_RE accepted a 1-character name; the middle group was optional. - Bound username and password length. Both endpoints taking them are unauthenticated and feed scrypt, so size drove CPU and memory. - Never pair a wildcard CORS origin with allow_credentials. - Enable SQLite foreign keys, so deleting an account no longer strands tokens. - Reject explicit null visibility/tags in PATCH, which returned 500 not 422. - Increment views and forkCount in SQL; the contract promises atomicity. - Allocate version numbers from max(number) with retry, so concurrent content updates cannot collide on a key and overwrite each other. - Stream thumbnail uploads instead of buffering the whole body before the check. - Build the app in a uvicorn factory; importing the module created a stray database and storage directory, including under pytest. - Return the documented account shape from /api/users/me. Packaging and docs: - Run the relay container as non-root with a healthcheck, and document the one-time chown for volumes created by an earlier root-owned image. - Re-exclude collab-node build artifacts after the .dockerignore negations. - Extend tsconfig.base.json, use the workspace "*" dependency convention, drop the duplicated SocketAttachment members and the dead socketByClientId, and share HEX_COLOR_RE/finite from one internal module. - Document `mine=true`, correct the users/{username}/projects auth description to the filtered-200 behavior the code and its tests implement, and state plainly that rate limiting and token expiry are left to the operator. - Cover unlisted visibility, explicit-null PATCH, username length, the configurable snapshot ceiling, and sanitizeView; drop the fixed sleep and the hardcoded /tmp path from the relay test. --- .dockerignore | 6 + backend/geolibre_server_api/README.md | 19 +++ .../geolibre_server_api/main.py | 132 ++++++++++++++---- backend/geolibre_server_api/tests/test_api.py | 60 +++++++- docs/server-api.md | 40 ++++-- packages/collab-core/src/comment-validate.ts | 8 +- packages/collab-core/src/index.ts | 9 ++ packages/collab-core/src/internal/validate.ts | 13 ++ packages/collab-core/src/protocol.ts | 4 +- packages/collab-core/src/session.ts | 12 +- packages/collab-core/tsconfig.json | 6 +- tests/collab-core-conformance.test.ts | 29 ++++ workers/collab-node/Dockerfile | 7 + workers/collab-node/README.md | 14 ++ workers/collab-node/package.json | 2 +- workers/collab-node/src/server.ts | 111 +++++++++++++-- workers/collab-node/src/store.ts | 29 ++-- workers/collab-node/test/relay.test.ts | 11 +- workers/collab/package.json | 2 +- workers/collab/src/session.ts | 81 ++++------- 20 files changed, 456 insertions(+), 139 deletions(-) create mode 100644 packages/collab-core/src/internal/validate.ts diff --git a/.dockerignore b/.dockerignore index 37128eb74d..43c5d67a90 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,12 @@ backend/**/__pycache__ workers/* !workers/collab-node !workers/collab-node/** +# Re-narrowed after the negations above: .dockerignore is last-match-wins, so +# without these the re-include would pull a host-built node_modules/dist back +# into the build context that the earlier **/node_modules rule had excluded. +workers/collab-node/node_modules +workers/collab-node/dist +workers/collab-node/data docs sample-data coverage diff --git a/backend/geolibre_server_api/README.md b/backend/geolibre_server_api/README.md index c123de4bcb..c25695b695 100644 --- a/backend/geolibre_server_api/README.md +++ b/backend/geolibre_server_api/README.md @@ -21,3 +21,22 @@ Configuration: - `GEOLIBRE_VIEWER_URL`: GeoLibre viewer origin. - `GEOLIBRE_CORS_ORIGINS`: comma-separated web origins, default `*`. - `GEOLIBRE_MAX_PROJECT_BYTES`, `GEOLIBRE_MAX_THUMBNAIL_BYTES`: upload limits. + +## Volume ownership + +The container runs as the unprivileged `geolibre` user, and the image creates +`/data/objects` so a fresh named volume inherits that ownership. Docker applies +image ownership only to a volume it creates, so one that already holds data from +an image that ran as root stays root-owned and every upload fails with +`PermissionError`. Repair it once with + +```bash +docker run --rm -v geolibre_geolibre-projects:/data/objects busybox \ + chown -R 1000:1000 /data/objects +``` + +## Hardening + +`429` and token expiry are part of the contract but are not implemented here; +see the "What the reference server leaves to the operator" section of +`docs/server-api.md` before exposing this publicly. diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index 5f886affd8..c7a8e066c1 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -27,9 +27,12 @@ UniqueConstraint, create_engine, delete, + event, func, select, + update, ) +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import ( DeclarativeBase, Mapped, @@ -40,7 +43,10 @@ ) Visibility = Literal["public", "unlisted", "private"] -USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,37}[a-z0-9])?$") +# 3-39 chars, starting and ending alphanumeric. The middle group is *not* +# optional: making it so would let a single character through, which contradicts +# both the error text and the limits table in docs/server-api.md. +USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$") SLUG_RE = re.compile(r"[^a-z0-9]+") IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"} @@ -103,8 +109,11 @@ class Version(Base): class Credentials(BaseModel): - username: str - password: str + # Both endpoints taking this model are unauthenticated, and password_hash + # feeds the value straight to scrypt (n=2**14, ~16 MiB per call). Without an + # upper bound a caller can drive that cost with an arbitrarily large body. + username: str = Field(max_length=39) + password: str = Field(max_length=1024) class ProjectCreate(BaseModel): @@ -253,6 +262,15 @@ def create_app( ) connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} engine = create_engine(database_url, connect_args=connect_args) + if database_url.startswith("sqlite"): + # SQLite disables foreign keys per connection, which makes every + # ondelete="CASCADE" inert. The ORM cascade covers projects and versions, + # but tokens have no relationship, so deleting an account would otherwise + # strand its tokens. + @event.listens_for(engine, "connect") + def _enable_foreign_keys(dbapi_connection, _record): # pragma: no cover - driver hook + dbapi_connection.execute("PRAGMA foreign_keys=ON") + Base.metadata.create_all(engine) sessions = sessionmaker(engine, expire_on_commit=False) object_storage = storage or make_storage() @@ -264,11 +282,17 @@ def create_app( app = FastAPI(title="GeoLibre projects and identity API", version="1.0") app.state.engine = engine app.state.storage = object_storage - origins = [x.strip() for x in os.getenv("GEOLIBRE_CORS_ORIGINS", "*").split(",")] + origins = [x.strip() for x in os.getenv("GEOLIBRE_CORS_ORIGINS", "*").split(",") if x.strip()] + # CORSMiddleware treats a "*" anywhere in the list as allow-all, so a value + # like "*,https://app.example" would otherwise pair allow-all with + # allow_credentials=True (the list is not exactly ["*"]) and accept + # credentialed requests from any origin. Wildcard wins, and drops credentials + # with it. + wildcard = "*" in origins app.add_middleware( CORSMiddleware, - allow_origins=origins, - allow_credentials=origins != ["*"], + allow_origins=["*"] if wildcard else origins, + allow_credentials=not wildcard, allow_methods=["*"], allow_headers=["Authorization", "Content-Type"], ) @@ -446,7 +470,9 @@ def get_account(account: Account = Depends(required_account)): @app.get("/api/users/me") def get_current_user(account: Account = Depends(required_account)): - return {"user": {"username": account.username}} + # The full account shape, matching what docs/server-api.md publishes and + # what /api/account returns. The gallery client reads only `username`. + return {"user": account_json(account)} @app.get("/api/users/{username}/projects") def get_user_projects( @@ -542,9 +568,15 @@ def patch_project( if "description" in updates: project.description = updates["description"] or "" if "visibility" in updates: + # exclude_unset keeps a field the client sent as an explicit null, so + # these two need their own guards: null visibility would hit a + # non-nullable column at commit, and null tags would reach len(). + # Both surfaced as an unhandled 500 rather than a 422. + if updates["visibility"] is None: + raise HTTPException(422, "visibility must not be null") project.visibility = updates["visibility"] if "tags" in updates: - tags = updates["tags"] + tags = updates["tags"] or [] if len(tags) > 20 or any(not tag or len(tag) > 40 for tag in tags): raise HTTPException(422, "tags must contain at most 20 non-empty 40-character tags") project.tags_json = json.dumps(tags) @@ -561,11 +593,33 @@ def update_content( ): project = owned(session.get(Project, project_id), account) parse_content(body.content, max_project_bytes) - number = len(project.versions) + 1 - key = f"projects/{project.id}/versions/{number}.json" + # Allocated from max(number) and committed *before* the object is + # written. Deriving it from len(project.versions) let two concurrent + # updates pick the same number: both wrote the same storage key, the + # second lost the primary-key race with a 500, and the winner's content + # had already been overwritten. Reserving the row first means a loser + # fails before touching storage, and can retry on the next free number. + for _ in range(5): + number = ( + session.scalar( + select(func.max(Version.number)).where(Version.project_id == project.id) + ) + or 0 + ) + 1 + key = f"projects/{project.id}/versions/{number}.json" + session.add( + Version(project_id=project.id, number=number, object_key=key, created_at=now()) + ) + try: + session.flush() + break + except IntegrityError: + session.rollback() + project = owned(session.get(Project, project_id), account) + else: + raise HTTPException(409, "could not allocate a version number; retry") object_storage.put(key, body.content.encode(), "application/json") project.updated_at = now() - session.add(Version(project_id=project.id, number=number, object_key=key, created_at=now())) session.commit() session.refresh(project) return {"project": project_json(project), "version": number} @@ -584,10 +638,12 @@ def delete_project_route( @app.post("/api/projects/{project_id}/forks", status_code=201) def fork_project( project_id: str, - # Defaulted so the body may be omitted entirely: "fork this project" with + # Optional so the body may be omitted entirely: "fork this project" with # no options is the common call, and every field already has a default. - # Without this FastAPI treats the body as required and answers 422. - body: ForkRequest = ForkRequest(), + # Without this FastAPI treats the body as required and answers 422. The + # default is None rather than ForkRequest() so the model is not + # constructed at import time (ruff B008). + body: ForkRequest | None = None, account: Account = Depends(required_account), session: Session = Depends(db), ): @@ -598,10 +654,15 @@ def fork_project( account, content, source.title + ".geolibre.json", - body.visibility, + (body or ForkRequest()).visibility, commit=False, ) - source.fork_count += 1 + # Incremented in SQL rather than read-modify-write in Python, so + # concurrent forks cannot lose each other's increments. The contract in + # docs/server-api.md promises this counter rises atomically. + session.execute( + update(Project).where(Project.id == source.id).values(fork_count=Project.fork_count + 1) + ) session.commit() session.refresh(fork) return {"project": project_json(fork)} @@ -644,9 +705,18 @@ async def put_thumbnail( content_type = request.headers.get("content-type", "").split(";")[0] if content_type not in IMAGE_TYPES: raise HTTPException(422, "thumbnail must be PNG, JPEG, or WebP") - data = await request.body() - if len(data) > max_thumbnail_bytes: - raise HTTPException(413, f"thumbnail exceeds the {max_thumbnail_bytes} byte limit") + # Streamed rather than `await request.body()`, which materializes the + # whole upload before the size is ever checked: an authenticated caller + # could otherwise push a multi-gigabyte body and exhaust worker memory to + # earn a 413. Aborting mid-stream caps what is ever held. + chunks: list[bytes] = [] + total = 0 + async for chunk in request.stream(): + total += len(chunk) + if total > max_thumbnail_bytes: + raise HTTPException(413, f"thumbnail exceeds the {max_thumbnail_bytes} byte limit") + chunks.append(chunk) + data = b"".join(chunks) object_storage.put(f"projects/{project.id}/thumbnail", data, content_type) project.thumbnail_type = content_type project.updated_at = now() @@ -691,9 +761,14 @@ def latest_raw( select(Project).join(Account).where(Account.username == username, Project.slug == slug) ) project = visible(project, account) - project.views += 1 + # Read the object first: a missing object is a 404 that should not count + # as a view. Incremented in SQL so concurrent reads do not lose counts. + body = raw_response(project, project.versions[-1], False) + session.execute( + update(Project).where(Project.id == project.id).values(views=Project.views + 1) + ) session.commit() - return raw_response(project, project.versions[-1], False) + return body @app.get("/{username}/{slug}") def project_page( @@ -712,10 +787,17 @@ def project_page( return app -app = create_app() - - def run() -> None: import uvicorn - uvicorn.run("geolibre_server_api.main:app", host="0.0.0.0", port=8000) + # A factory, not a module-level `app = create_app()`. Building the app at + # import time opens the database and creates the storage root as a side + # effect of importing this module -- which the test suite does, leaving a + # stray ./geolibre-server-api.db and ./data in whatever directory pytest ran + # from. + uvicorn.run( + "geolibre_server_api.main:create_app", + factory=True, + host=os.getenv("GEOLIBRE_HOST", "0.0.0.0"), # noqa: S104 - containers bind all interfaces + port=int(os.getenv("GEOLIBRE_PORT", "8000")), + ) diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py index 9c35e8742e..09d319598b 100644 --- a/backend/geolibre_server_api/tests/test_api.py +++ b/backend/geolibre_server_api/tests/test_api.py @@ -3,19 +3,20 @@ import pytest from fastapi.testclient import TestClient -from geolibre_server_api.main import create_app +from geolibre_server_api.main import FileStorage, create_app @pytest.fixture def client(tmp_path): + # Storage is constructed explicitly rather than left to make_storage(), which + # reads GEOLIBRE_STORAGE/GEOLIBRE_STORAGE_PATH from the ambient environment: + # that both created a ./data directory in the pytest working directory and + # would hand back an S3Storage if GEOLIBRE_STORAGE=s3 happened to be set. app = create_app( f"sqlite:///{tmp_path / 'test.db'}", public_url="https://share.example", - storage=None, + storage=FileStorage(str(tmp_path / "objects")), ) - # create_app reads the storage path only if no implementation is passed. - app.state.storage.root = tmp_path / "objects" - app.state.storage.root.mkdir() with TestClient(app) as test_client: yield test_client @@ -52,7 +53,11 @@ def test_accounts_login_current_user_and_hashed_secrets(client): token = account(client) assert client.get("/api/account", headers=auth(token)).json()["account"]["username"] == "ada" - assert client.get("/api/users/me", headers=auth(token)).json() == {"user": {"username": "ada"}} + me = client.get("/api/users/me", headers=auth(token)).json()["user"] + # Asserted field-wise, not by exact equality: pinning the whole dict froze the + # response to a single key and hid the drift from the published contract. + assert me["username"] == "ada" + assert me["id"] and me["createdAt"] login = client.post("/api/auth/token", json={"username": "ada", "password": "correct horse"}) assert login.status_code == 200 assert login.json()["token"] != token @@ -112,6 +117,49 @@ def test_project_crud_visibility_listing_and_raw_views(client): assert client.get(f"/api/projects/{project_id}").status_code == 404 +def test_unlisted_is_hidden_from_listings_but_readable_by_url(client): + """`unlisted` sits between public and private and had no coverage: it is kept + out of every listing a non-owner sees, yet anyone holding the URL can read it.""" + owner = account(client) + other = account(client, "grace") + project, content = create_project(client, owner, "unlisted", title="Hidden") + + assert client.get("/api/projects").json()["projects"] == [] + assert client.get("/api/users/ada/projects", headers=auth(other)).json()["projects"] == [] + assert len(client.get("/api/users/ada/projects", headers=auth(owner)).json()["projects"]) == 1 + assert len(client.get("/api/projects?mine=true", headers=auth(owner)).json()["projects"]) == 1 + + anonymous = client.get(project["rawJsonUrl"].removeprefix("https://share.example")) + assert anonymous.status_code == 200 and anonymous.json() == json.loads(content) + + +def test_patch_rejects_explicit_nulls(client): + """An explicit JSON null survives `exclude_unset`, so these must be 422s rather + than a non-nullable column error or a len(None) crash at 500.""" + owner = account(client) + project, _ = create_project(client, owner) + path = f"/api/projects/{project['id']}" + assert client.patch(path, headers=auth(owner), json={"visibility": None}).status_code == 422 + assert client.patch(path, headers=auth(owner), json={"tags": None}).status_code == 200 + assert client.get(path).json()["project"]["tags"] == [] + + +def test_username_length_is_enforced(client): + """The optional middle group in the old pattern let a 1-character username + through, contradicting both the error text and the documented limits.""" + for name in ("a", "ab"): + response = client.post( + "/api/accounts", json={"username": name, "password": "correct horse"} + ) + assert response.status_code == 422, name + assert ( + client.post( + "/api/accounts", json={"username": "abc", "password": "correct horse"} + ).status_code + == 201 + ) + + def test_thumbnail_fork_and_slug_collision(client): owner = account(client) recipient = account(client, "grace") diff --git a/docs/server-api.md b/docs/server-api.md index a74effec6b..9e4f71584d 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -24,6 +24,24 @@ implementation or storage engine. The reference implementation lives in - CORS deployments must allow `Authorization` and `Content-Type` from the GeoLibre web origin. Native desktop requests do not depend on CORS. +## What the reference server leaves to the operator + +Two parts of the contract above are deliberately not implemented in +`backend/geolibre_server_api`, and an operator exposing it publicly has to +supply them: + +- **Rate limiting.** `429` is in the error vocabulary, but no route returns it. + `POST /api/auth/token` and `POST /api/accounts` are unauthenticated and run + scrypt on every call, so without a limiter in front they allow password + brute-forcing, username enumeration through the `409`/`401` distinction, and + a cheap CPU-burn. Put a reverse proxy or WAF limit on both, keyed by client IP + and by username. +- **Token expiry.** `401` covers an expired token, but tokens issued here do not + carry an expiry and stay valid until `DELETE /api/auth/token` revokes them. + +Both are contract-level capabilities a compatible server may implement; the +reference implementation is a correctness baseline, not a hardened deployment. + ## Limits | Field | Limit | @@ -172,18 +190,24 @@ Query parameters: - `limit`: integer page size. - `offset`: non-negative number of matching records to skip. - `featured=true`: return featured projects only. +- `mine=true`: return the caller's own projects, including unlisted and private + ones. Requires auth; without a valid token this is `401`. -Only public projects are returned. An Authorization header does not broaden a -public listing by itself. Invalid pagination is `422`. +Only public projects are returned unless `mine=true` is set. An Authorization +header does not broaden a public listing by itself. Invalid pagination is `422`. ### `GET /api/users/{username}/projects` -Requires auth. When `{username}` is the caller's username, returns -`{"projects": [...]}` containing all projects owned by the caller, including -unlisted and private projects, in newest-updated-first order. The current client -first resolves its username through `GET /api/users/me`, then calls this route. -Requesting another user's non-public listing returns `403` (an implementation -may instead return that user's public projects if it documents that extension). +Returns `{"projects": [...]}` owned by `{username}`, in newest-updated-first +order. Auth is optional and decides the breadth of the result: when the token +identifies `{username}`, the listing includes their unlisted and private +projects; every other caller, authenticated or not, sees only that user's public +projects. The current client first resolves its username through +`GET /api/users/me`, then calls this route. + +A non-owner therefore gets a filtered `200`, not a `403` — the listing narrows +rather than refusing, which keeps a user's existence from being probed through +the status code. ### `GET /api/projects/{id}` diff --git a/packages/collab-core/src/comment-validate.ts b/packages/collab-core/src/comment-validate.ts index b57cb555ad..1b65e58f42 100644 --- a/packages/collab-core/src/comment-validate.ts +++ b/packages/collab-core/src/comment-validate.ts @@ -2,6 +2,8 @@ // `ProjectComment` / `CommentReply` shapes from `@geolibre/core` but operate on // untrusted `unknown` input, returning a sanitized object or `null`. +import { finite, HEX_COLOR_RE } from "./internal/validate"; + /** Body length cap — matches the chat limit so comments can't store unbounded text. */ export const MAX_COMMENT_BODY_LENGTH = 2000; @@ -48,12 +50,6 @@ export function preserveStoredComments(project: unknown, stored: unknown): unkno return { ...(project as Record), comments }; } -const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - -function finite(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n); -} - // -- anchor ------------------------------------------------------------------- interface PointAnchor { diff --git a/packages/collab-core/src/index.ts b/packages/collab-core/src/index.ts index 79ae0a81f3..707c50ed87 100644 --- a/packages/collab-core/src/index.ts +++ b/packages/collab-core/src/index.ts @@ -1,3 +1,12 @@ +// Transport-neutral core of the collaboration relay: the wire protocol, the +// permission decisions, and the payload validators, with no host API in sight. +// Both the Cloudflare Worker and the Node relay build on this, and +// `tests/collab-core-conformance.test.ts` pins the behaviour they must share. +// +// tsconfig.json narrows `lib` back to ES2022 (the repo base adds DOM) on +// purpose: this code has to compile for a Worker and for plain Node, so +// reaching for a browser global should be a type error, not a runtime crash. + export * from "./comment-validate"; export * from "./protocol"; export * from "./session"; diff --git a/packages/collab-core/src/internal/validate.ts b/packages/collab-core/src/internal/validate.ts new file mode 100644 index 0000000000..35f955ef4e --- /dev/null +++ b/packages/collab-core/src/internal/validate.ts @@ -0,0 +1,13 @@ +// Shared validation primitives for the relay core. +// +// Kept in one place because both `session.ts` and `comment-validate.ts` need +// them and they are validation *contracts*: two copies of the colour pattern +// would let `sanitizeColor` and `validateAuthor` drift into accepting different +// values for the same session. Not re-exported from the package root, so the +// public surface is unchanged. + +export const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +export function finite(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n); +} diff --git a/packages/collab-core/src/protocol.ts b/packages/collab-core/src/protocol.ts index f024bf5b63..ae583d353c 100644 --- a/packages/collab-core/src/protocol.ts +++ b/packages/collab-core/src/protocol.ts @@ -1,6 +1,8 @@ // Wire protocol for the live-collaboration relay. // -// This is the worker-side copy. The frontend keeps a parallel copy in +// This is the single relay-side definition: both the Cloudflare Worker +// (`workers/collab/src/protocol.ts` re-exports it) and the Node relay build on +// it, as does the conformance suite. The frontend keeps a parallel copy in // `apps/geolibre-desktop/src/lib/collab-protocol.ts` with the `project` field // typed as the concrete `GeoLibreProject`. The relay never inspects a project's // contents — it only stores and forwards the opaque JSON — so here `project` is diff --git a/packages/collab-core/src/session.ts b/packages/collab-core/src/session.ts index c7c9fe1f1a..940d6c498e 100644 --- a/packages/collab-core/src/session.ts +++ b/packages/collab-core/src/session.ts @@ -5,6 +5,7 @@ import type { CollaborationMode, CollaborationRole, } from "./protocol"; +import { finite, HEX_COLOR_RE } from "./internal/validate"; export const MAX_SNAPSHOT_BYTES = 1_000_000; export const EMPTY_SESSION_TTL_MS = 2 * 60 * 60 * 1000; @@ -13,12 +14,6 @@ export const CHAT_HISTORY_LIMIT = 50; export const MAX_CHAT_STORAGE_BYTES = 100_000; export const MIN_CHAT_INTERVAL_MS = 250; -const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - -function finite(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n); -} - /** * Transport-neutral state attached to one connection. Adapters may keep this * in memory (Node) or serialize it onto a hibernatable socket (Cloudflare). @@ -119,7 +114,10 @@ export function toWireParticipant(participant: SessionParticipant): CollabPartic } export function sanitizeDisplayName(value: unknown): string { - return (typeof value === "string" ? value : "").slice(0, 60) || "Guest"; + // Trimmed before the fallback: a whitespace-only string is truthy, so " " + // would otherwise pass through as the name in the roster, every participants + // broadcast, and every chat author field. `validateAuthor` already trims. + return (typeof value === "string" ? value.trim() : "").slice(0, 60) || "Guest"; } export function sanitizeColor(value: unknown): string { diff --git a/packages/collab-core/tsconfig.json b/packages/collab-core/tsconfig.json index 90d3a82812..c64d8b6d91 100644 --- a/packages/collab-core/tsconfig.json +++ b/packages/collab-core/tsconfig.json @@ -1,11 +1,7 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", "lib": ["ES2022"], - "strict": true, - "skipLibCheck": true, "noEmit": true }, "include": ["src/**/*.ts"] diff --git a/tests/collab-core-conformance.test.ts b/tests/collab-core-conformance.test.ts index d70463d105..bd64aa5a16 100644 --- a/tests/collab-core-conformance.test.ts +++ b/tests/collab-core-conformance.test.ts @@ -10,6 +10,7 @@ import { sanitizeColor, sanitizeCursor, sanitizeDisplayName, + sanitizeView, setParticipantOverride, toWireParticipant, type SessionParticipant, @@ -76,6 +77,30 @@ describe("collaboration relay conformance", () => { const decision = authorizeSnapshot(host, "co-edit", MAX_SNAPSHOT_BYTES + 1); assert.equal(decision.ok, false); if (!decision.ok) assert.equal(decision.code, "too-large"); + + // The Node relay passes its own configured ceiling while the Worker takes + // the default, so a regression that ignored `maxBytes` would still satisfy + // the assertions above. Pin the explicit argument too. + assert.deepEqual(authorizeSnapshot(host, "co-edit", 64, 64), { ok: true }); + const custom = authorizeSnapshot(host, "co-edit", 65, 64); + assert.equal(custom.ok, false); + if (!custom.ok) assert.equal(custom.code, "too-large"); + }); + + it("sanitizes untrusted presence viewports", () => { + // Both relays run sanitizeView over presence frames, so its contract belongs + // in the shared suite alongside the permission rules. + assert.equal(sanitizeView(null), null); + assert.equal(sanitizeView({ center: "nope" }), null); + assert.equal(sanitizeView({ center: [Number.NaN, 1] }), null); + assert.deepEqual(sanitizeView({ center: [1, 2] }), { + center: [1, 2], + zoom: 0, + bearing: 0, + pitch: 0, + }); + assert.deepEqual(sanitizeView({ center: [1, 2], bbox: [1, 2, 3, 4] })?.bbox, [1, 2, 3, 4]); + assert.equal(sanitizeView({ center: [1, 2], bbox: [1, 2, 3] })?.bbox, undefined); }); it("clears sticky overrides when the host changes the session mode", () => { @@ -93,6 +118,10 @@ describe("collaboration relay conformance", () => { const guest = participant("guest"); assert.equal(toWireParticipant(guest).editOverride, null); assert.equal(sanitizeDisplayName(42), "Guest"); + // A whitespace-only name is truthy, so it used to slip past the fallback and + // reach the roster, every participants broadcast, and every chat author. + assert.equal(sanitizeDisplayName(" "), "Guest"); + assert.equal(sanitizeDisplayName(" Ada "), "Ada"); assert.equal(sanitizeColor("red"), "#888888"); assert.deepEqual(sanitizeCursor({ lng: -71, lat: 42, extra: "drop" }), { lng: -71, diff --git a/workers/collab-node/Dockerfile b/workers/collab-node/Dockerfile index 6277f548b8..f1be05ad7d 100644 --- a/workers/collab-node/Dockerfile +++ b/workers/collab-node/Dockerfile @@ -14,6 +14,13 @@ WORKDIR /app COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/workers/collab-node/package.json ./workers/collab-node/package.json COPY --from=build /app/workers/collab-node/dist ./workers/collab-node/dist +# /data is created and owned here so a named volume mounted over it inherits the +# ownership; Docker only copies image ownership onto a volume when the path +# already exists, otherwise the mountpoint lands root-owned. +RUN mkdir -p /data && chown -R node:node /data /app VOLUME ["/data"] EXPOSE 8787 +USER node +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "workers/collab-node/dist/server.js"] diff --git a/workers/collab-node/README.md b/workers/collab-node/README.md index a78340c584..d99155aeaf 100644 --- a/workers/collab-node/README.md +++ b/workers/collab-node/README.md @@ -22,3 +22,17 @@ Configuration: Endpoints are `POST /sessions`, `GET /sessions/:id/ws`, and `GET /health`. Persist the directory containing `COLLAB_DB_PATH`, and terminate TLS at the ingress so browsers can connect with `wss://`. + +### Volume ownership + +The container runs as the unprivileged `node` user, and the image creates +`/data` so a fresh named volume inherits that ownership. Docker only does this +for a volume it creates: a volume that already has content from an image that +ran as root keeps its root ownership, and the relay then exits at boot with +`SQLITE_READONLY: attempt to write a readonly database`. Repair it once with + +```bash +docker run --rm -v geolibre_geolibre-collab:/data busybox chown -R 1000:1000 /data +``` + +The same applies to the projects server's volume, using its `geolibre` user. diff --git a/workers/collab-node/package.json b/workers/collab-node/package.json index 60b5c80792..fd22e4e47f 100644 --- a/workers/collab-node/package.json +++ b/workers/collab-node/package.json @@ -10,7 +10,7 @@ "start": "node dist/server.js" }, "dependencies": { - "@geolibre/collab-core": "0.0.0", + "@geolibre/collab-core": "*", "ws": "^8.18.0" }, "devDependencies": { diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index 406f9e7210..055d849ad9 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -101,6 +101,32 @@ export function createRelay(options: RelayOptions = {}): { const sessions = new Map(); const wss = new WebSocketServer({ noServer: true, maxPayload: maxSnapshotBytes + 64_000 }); + // `POST /sessions` is unauthenticated and inserts a row per call, while the + // only delete path runs after a peer has connected *and* disconnected. A code + // nobody ever joins would otherwise sit in SQLite forever. + const sweep = (): void => store.deleteStaleBefore(Date.now() - idleTtlMs, sessions.keys()); + sweep(); + const sweepTimer = setInterval(sweep, Math.min(idleTtlMs, 15 * 60 * 1000)); + sweepTimer.unref(); + + // A TCP connection dropped without a close frame (NAT or proxy idle timeout) + // fires neither "close" nor "error", so the peer would linger in the roster + // forever and keep peers.size above zero, which blocks the idle cleanup above. + const alive = new WeakSet(); + const heartbeat = setInterval(() => { + for (const session of sessions.values()) { + for (const peer of session.peers) { + if (!alive.has(peer.socket)) { + peer.socket.terminate(); + continue; + } + alive.delete(peer.socket); + peer.socket.ping(); + } + } + }, 30_000); + heartbeat.unref(); + function live(id: string): LiveSession { let session = sessions.get(id); if (!session) { @@ -153,17 +179,39 @@ export function createRelay(options: RelayOptions = {}): { peer: Peer, raw: WebSocket.RawData, ): void { - if (!raw || Array.isArray(raw) || raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) { - // ws delivers text as Buffer too; isBinary is handled at the event site. - } + // ws delivers text as Buffer too; isBinary is handled at the event site. const text = raw.toString(); let message: ClientMessage; try { - message = JSON.parse(text) as ClientMessage; + // `null`, `1` and `"x"` are all valid JSON, so parsing alone does not + // guarantee an object. Reading `.type` off a non-object throws inside the + // ws "message" listener, which is an uncaught exception that takes the + // whole relay -- and every other session it hosts -- down with it. + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("not an object"); + } + message = parsed as ClientMessage; } catch { send(peer, { type: "error", code: "bad-message", message: "Malformed JSON." }); return; } + // Presence is by far the highest-frequency frame (one per cursor move, per + // participant) and needs no persisted state, so it is answered before the + // store read below. store.get is a synchronous SQLite SELECT * that pulls + // the snapshot and chat blobs and blocks the event loop; because this relay + // hosts every session in one process, doing that per presence frame would + // add latency to every *other* session too. The Cloudflare DO can afford the + // per-message read because each session is its own isolated actor. + if (message.type === "presence" && peer.participant) { + const cursor = sanitizeCursor(message.cursor); + const view = sanitizeView(message.view); + const clientId = peer.participant.clientId; + session.presence.set(clientId, { cursor, view }); + broadcast(session, { type: "presence", clientId, cursor, view }, peer); + return; + } + const persisted = store.get(id); if (!persisted) { peer.socket.close(1008, "Unknown session"); @@ -228,13 +276,9 @@ export function createRelay(options: RelayOptions = {}): { return; } - if (message.type === "presence") { - const cursor = sanitizeCursor(message.cursor); - const view = sanitizeView(message.view); - session.presence.set(participant.clientId, { cursor, view }); - broadcast(session, { type: "presence", clientId: participant.clientId, cursor, view }, peer); - return; - } + // A presence frame from a peer that has not joined yet falls through to + // here, where the join guard above has already rejected it. + if (message.type === "presence") return; if (message.type === "set-mode") { const authorization = authorizeHostAction(participant, "session mode"); @@ -434,11 +478,27 @@ export function createRelay(options: RelayOptions = {}): { return json(response, 200, { ok: true, service: "geolibre-collab" }); if (url.pathname === "/sessions" && request.method === "POST") { let raw = ""; + let aborted = false; request.setEncoding("utf8"); + // An unhandled "error" on the request stream (a client aborting mid-upload) + // throws and would terminate the process. + request.on("error", () => { + aborted = true; + }); request.on("data", (chunk) => { - if (raw.length < 16_384) raw += chunk; + if (aborted) return; + if (raw.length < 16_384) { + raw += chunk; + return; + } + // Stopping at the cap but continuing to read let a client stream + // unbounded data at us; destroy the request instead. + aborted = true; + request.destroy(); + json(response, 413, { error: "Request body too large." }); }); request.on("end", () => { + if (aborted) return; let requested: unknown = {}; try { requested = raw ? JSON.parse(raw) : {}; @@ -474,6 +534,8 @@ export function createRelay(options: RelayOptions = {}): { const session = live(id); const peer: Peer = { socket: websocket }; session.peers.add(peer); + alive.add(websocket); + websocket.on("pong", () => alive.add(websocket)); websocket.on("message", (raw, isBinary) => { if (isBinary) { send(peer, { @@ -483,7 +545,14 @@ export function createRelay(options: RelayOptions = {}): { }); return; } - handleMessage(id, session, peer, raw); + // Defence in depth: this listener runs outside any promise chain, so an + // throw here is an uncaught exception that stops the whole relay. No + // single session's frame should be able to do that to the others. + try { + handleMessage(id, session, peer, raw); + } catch { + send(peer, { type: "error", code: "bad-message", message: "Could not handle message." }); + } }); websocket.on("close", () => closePeer(id, session, peer)); websocket.on("error", () => closePeer(id, session, peer)); @@ -494,13 +563,27 @@ export function createRelay(options: RelayOptions = {}): { server, store, close: async () => { + clearInterval(sweepTimer); + clearInterval(heartbeat); + const open: WebSocket[] = []; for (const session of sessions.values()) { if (session.cleanup) clearTimeout(session.cleanup); - for (const peer of session.peers) peer.socket.close(1001, "Server shutting down"); + for (const peer of session.peers) { + peer.socket.close(1001, "Server shutting down"); + open.push(peer.socket); + } } + // close() only *starts* the closing handshake. server.close() waits for + // every connection to end, so a client that never answers would hang + // shutdown indefinitely. + const grace = setTimeout(() => { + for (const socket of open) socket.terminate(); + }, 1000); + grace.unref(); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); + clearTimeout(grace); wss.close(); store.close(); }, diff --git a/workers/collab-node/src/store.ts b/workers/collab-node/src/store.ts index a60a604760..990c8a7ec4 100644 --- a/workers/collab-node/src/store.ts +++ b/workers/collab-node/src/store.ts @@ -80,18 +80,17 @@ export class SessionStore { } saveSnapshot(id: string, project: unknown): number { - this.db + // RETURNING keeps the write and the revision read in one statement, so the + // number handed back is always the one this update produced. + const row = this.db .prepare( `UPDATE collab_sessions SET snapshot = ?, rev = rev + 1, updated_at = ? - WHERE id = ?`, + WHERE id = ? + RETURNING rev`, ) - .run(JSON.stringify(project), Date.now(), id); - return ( - this.db.prepare("SELECT rev FROM collab_sessions WHERE id = ?").get(id) as { - rev: number; - } - ).rev; + .get(JSON.stringify(project), Date.now(), id) as { rev: number } | undefined; + return row?.rev ?? 0; } saveProjectState(id: string, project: unknown): void { @@ -116,6 +115,20 @@ export class SessionStore { this.db.prepare("DELETE FROM collab_sessions WHERE id = ?").run(id); } + /** + * Drop sessions untouched since `cutoff`, skipping any that are currently live + * in memory. Reclaims codes that were allocated by `POST /sessions` but never + * joined, which no socket-close path ever reaches. + */ + deleteStaleBefore(cutoff: number, keep: Iterable): void { + const keepSet = new Set(keep); + const rows = this.db + .prepare("SELECT id FROM collab_sessions WHERE updated_at < ?") + .all(cutoff) as { id: string }[]; + const remove = this.db.prepare("DELETE FROM collab_sessions WHERE id = ?"); + for (const row of rows) if (!keepSet.has(row.id)) remove.run(row.id); + } + close(): void { this.db.close(); } diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index 6ce1041550..585bf57013 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, it } from "node:test"; import { WebSocket } from "ws"; @@ -152,7 +153,7 @@ describe("Node collaboration relay", () => { }); it("restores the latest snapshot and revision from SQLite after restart", async () => { - const directory = await mkdtemp("/tmp/geolibre-collab-"); + const directory = await mkdtemp(join(tmpdir(), "geolibre-collab-")); const dbPath = join(directory, "relay.sqlite"); cleanups.push(() => rm(directory, { recursive: true, force: true })); @@ -160,8 +161,14 @@ describe("Node collaboration relay", () => { const created = await createSession(first.http); const host = await connect(first.http, created.sessionId); await joinSession(host, created.hostToken); + // A second peer observes the broadcast, which the relay only emits after the + // snapshot is written. Waiting on that beats a fixed sleep, which turns into + // a flake the moment CI is slower than the guess. + const observer = await connect(first.http, created.sessionId); + await joinSession(observer); host.send(JSON.stringify({ type: "snapshot", project: { persisted: true }, rev: 0 })); - await new Promise((resolve) => setTimeout(resolve, 25)); + await next(observer, "snapshot"); + observer.close(); host.close(); await cleanups.pop()?.(); diff --git a/workers/collab/package.json b/workers/collab/package.json index 987b82bce9..6cf12a2b2a 100644 --- a/workers/collab/package.json +++ b/workers/collab/package.json @@ -8,7 +8,7 @@ "deploy": "wrangler deploy" }, "dependencies": { - "@geolibre/collab-core": "0.0.0" + "@geolibre/collab-core": "*" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260728.1", diff --git a/workers/collab/src/session.ts b/workers/collab/src/session.ts index ddc5c7d214..98713e4e89 100644 --- a/workers/collab/src/session.ts +++ b/workers/collab/src/session.ts @@ -61,42 +61,25 @@ export interface Env { COLLAB_SESSION: DurableObjectNamespace; } -// Cloudflare caps a single WebSocket message at ~1 MiB. Reject project -// snapshots above this so one oversized embedded FeatureCollection can't blow -// the actor; the client surfaces a "share via URL instead" hint. -// Reclaim an empty session's storage this long after the last socket closes, so -// abandoned codes don't accumulate. A rejoin before the alarm fires cancels it. -// Cap a single chat message so one frame can't store an unbounded string. -// How many recent chat messages to retain so a late joiner sees recent history. -// Persisted (not in-memory) so it survives a hibernation between messages. -// Hard byte budget for the persisted chat log, comfortably under Cloudflare's -// ~128 KiB per-value storage cap (multi-byte text can blow the count limit). -// Minimum gap between a socket's chat frames. Each chat costs a storage -// read+write and a fan-out, so silently drop bursts faster than this floor to -// keep one client from exhausting the session's storage-op budget. Generous -// enough that normal typing/sending is never affected. +// The snapshot cap, empty-session TTL, and chat limits now live in +// `@geolibre/collab-core` (imported above) so both relays enforce one set of +// numbers; see that module for why each value is what it is. // Stateless and reused across frames (snapshots can arrive several times a // second), so we don't allocate a new encoder per message. const ENCODER = new TextEncoder(); -interface SocketAttachment extends SessionParticipant { - clientId: string; - displayName: string; - color: string; - role: CollaborationRole; - /** - * Host-set per-participant edit override (#754, Part 3). `undefined` means - * "follow the session mode"; `true`/`false` pins this socket to can-edit / - * view-only. Stored on the attachment so it survives a hibernation wake and is - * never persisted to storage (it is keyed to a clientId, which is per-socket). - */ - editOverride?: boolean; - /** Epoch-ms of this socket's last accepted chat frame, for rate-limiting. */ - lastChatTs?: number; - /** Epoch-ms of this socket's last accepted comment-mutation frame. */ - lastCommentTs?: number; -} +/** + * The shared `SessionParticipant` state, serialized onto a hibernatable socket. + * `editOverride`, `lastChatTs`, and `lastCommentTs` ride on the attachment so + * they survive a hibernation wake; none is persisted to storage, because each is + * keyed to a per-socket clientId (#754, Part 3). + * + * Deliberately an alias rather than an interface restating the fields: a copy + * would compile fine while silently reintroducing the drift the extraction into + * `@geolibre/collab-core` exists to prevent. + */ +type SocketAttachment = SessionParticipant; /** Validate a single stored chat entry's field types so a corrupt record can't * reach clients (where it would, e.g., crash `coordinate.lat.toFixed`). */ @@ -449,16 +432,7 @@ export class CollabSession extends DurableObject { // host previously pinned to can-edit would keep editing through a later // switch to view-only (a "sticky override" footgun), and there is otherwise // no path to reset an override back to "follow the session mode". - const socketsWithAttachments = this.ctx - .getWebSockets() - .map((socket) => ({ - socket, - attachment: socket.deserializeAttachment() as SocketAttachment | null, - })) - .filter( - (entry): entry is { socket: WebSocket; attachment: SocketAttachment } => - entry.attachment !== null, - ); + const socketsWithAttachments = this.attachedSockets(); const clearedAny = clearParticipantOverrides( socketsWithAttachments.map((entry) => entry.attachment), ); @@ -490,16 +464,7 @@ export class CollabSession extends DurableObject { } // `message` is untrusted JSON, so guard the lookup key's type (mirrors the // strict-boolean coercion below) before matching it against attachments. - const socketsWithAttachments = this.ctx - .getWebSockets() - .map((socket) => ({ - socket, - attachment: socket.deserializeAttachment() as SocketAttachment | null, - })) - .filter( - (entry): entry is { socket: WebSocket; attachment: SocketAttachment } => - entry.attachment !== null, - ); + const socketsWithAttachments = this.attachedSockets(); const changed = setParticipantOverride( attachment, socketsWithAttachments.map((entry) => entry.attachment), @@ -583,12 +548,18 @@ export class CollabSession extends DurableObject { // -- helpers ---------------------------------------------------------------- - private socketByClientId(clientId: string): WebSocket | null { + /** + * Live sockets paired with their deserialized attachment. Callers mutate the + * returned attachment objects in place and must re-serialize that same + * instance for the change to survive a hibernation wake. + */ + private attachedSockets(): { socket: WebSocket; attachment: SocketAttachment }[] { + const result: { socket: WebSocket; attachment: SocketAttachment }[] = []; for (const socket of this.ctx.getWebSockets()) { - const a = socket.deserializeAttachment() as SocketAttachment | null; - if (a?.clientId === clientId) return socket; + const attachment = socket.deserializeAttachment() as SocketAttachment | null; + if (attachment) result.push({ socket, attachment }); } - return null; + return result; } private participants(except?: WebSocket): CollabParticipant[] { From 7e6a52ee7d12090a9894febd39a80ce339385003 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 22:52:10 -0400 Subject: [PATCH 04/11] Address CodeRabbit review feedback - Count the session-create body in UTF-8 bytes including the current chunk, and check before appending. Testing the accumulated length first accepted one final chunk of any size once the running total was still under the cap. - Answer the 413 before dropping the connection. Destroying the request immediately tore down the socket before the response could flush, so a client saw a reset instead of the status; verified against the container. - Use a level-two heading in the relay README (MD001). --- workers/collab-node/README.md | 2 +- workers/collab-node/src/server.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/workers/collab-node/README.md b/workers/collab-node/README.md index d99155aeaf..3032576e78 100644 --- a/workers/collab-node/README.md +++ b/workers/collab-node/README.md @@ -23,7 +23,7 @@ Endpoints are `POST /sessions`, `GET /sessions/:id/ws`, and `GET /health`. Persist the directory containing `COLLAB_DB_PATH`, and terminate TLS at the ingress so browsers can connect with `wss://`. -### Volume ownership +## Volume ownership The container runs as the unprivileged `node` user, and the image creates `/data` so a fresh named volume inherits that ownership. Docker only does this diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index 055d849ad9..4c3798ea8a 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -478,6 +478,7 @@ export function createRelay(options: RelayOptions = {}): { return json(response, 200, { ok: true, service: "geolibre-collab" }); if (url.pathname === "/sessions" && request.method === "POST") { let raw = ""; + let rawBytes = 0; let aborted = false; request.setEncoding("utf8"); // An unhandled "error" on the request stream (a client aborting mid-upload) @@ -487,14 +488,23 @@ export function createRelay(options: RelayOptions = {}): { }); request.on("data", (chunk) => { if (aborted) return; - if (raw.length < 16_384) { + // Counted in UTF-8 bytes *including* this chunk, and checked before the + // append: testing the running length first would accept one final chunk + // of any size once the total was still under the cap. + rawBytes += Buffer.byteLength(chunk); + if (rawBytes <= 16_384) { raw += chunk; return; } // Stopping at the cap but continuing to read let a client stream // unbounded data at us; destroy the request instead. aborted = true; - request.destroy(); + // Pause rather than destroy immediately: destroying the request tears + // down the socket before the 413 can flush, and the client sees a + // connection reset instead of the status. Stop reading, answer, then + // drop the connection once the response is on the wire. + request.pause(); + response.on("finish", () => request.destroy()); json(response, 413, { error: "Request body too large." }); }); request.on("end", () => { From b498b704a0686b3f76707052e72df259f8065340 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 22:55:37 -0400 Subject: [PATCH 05/11] Address Claude review feedback - Reject a declared Content-Length past the largest accepted body before reading it. The JSON `content` routes let Pydantic materialize the whole payload before parse_content could answer 413, the exposure the thumbnail route already avoided by streaming. - Retry slug allocation on IntegrityError in create_project. unique_slug SELECTs and the insert follows, so two concurrent creates with one title from the same account raced into a 500 rather than a clean retry. - Match the Worker in the Node relay's comment-mutation path: validate the action shape before checking permissions, so a malformed frame from a view-only guest answers bad-message on both relays rather than forbidden on one, and bound the mutated project against the snapshot ceiling before persisting it. The per-comment caps alone allow a worst case far past it. - Cover both in the relay suite, which did not exercise comment-mutation at all. --- .../geolibre_server_api/main.py | 56 ++++++++++++++----- backend/geolibre_server_api/tests/test_api.py | 13 +++++ workers/collab-node/src/server.ts | 34 ++++++++--- workers/collab-node/test/relay.test.ts | 49 ++++++++++++++++ 4 files changed, 131 insertions(+), 21 deletions(-) diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index c7a8e066c1..3de6541ddf 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -297,6 +297,22 @@ def _enable_foreign_keys(dbapi_connection, _record): # pragma: no cover - drive allow_headers=["Authorization", "Content-Type"], ) + # A declared Content-Length past the largest thing any route accepts is + # rejected before the body is read at all. Without this, the JSON `content` + # routes let Pydantic materialize the whole payload in memory *before* + # parse_content could answer 413 -- the same exposure the thumbnail route + # avoids by streaming. The per-route checks stay authoritative; this only + # sheds the obviously-too-big requests early. The slack covers JSON string + # escaping, which can inflate a document well past its decoded size. + body_ceiling = max(max_project_bytes * 2, max_thumbnail_bytes) + 1024 + + @app.middleware("http") + async def limit_body(request: Request, call_next): + declared = request.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > body_ceiling: + return JSONResponse({"error": "request body too large"}, status_code=413) + return await call_next(request) + @app.get("/health") def health(): return {"ok": True, "service": "geolibre-server"} @@ -407,19 +423,33 @@ def create_project( document = parse_content(content, max_project_bytes) title = title_from(document, filename) timestamp = now() - project = Project( - id=str(uuid.uuid4()), - owner_id=account.id, - slug=unique_slug(session, account.id, title or filename), - title=title, - description="", - visibility=visibility, - tags_json="[]", - created_at=timestamp, - updated_at=timestamp, - ) - session.add(project) - session.flush() + # unique_slug SELECTs and this INSERTs, so two concurrent creates with + # the same title from one account can pick the same slug and the loser + # hits uq_project_owner_slug. Retry the allocation instead of surfacing + # that as a 500, matching how version numbers are allocated below. + for _ in range(5): + project = Project( + id=str(uuid.uuid4()), + owner_id=account.id, + slug=unique_slug(session, account.id, title or filename), + title=title, + description="", + visibility=visibility, + tags_json="[]", + created_at=timestamp, + updated_at=timestamp, + ) + session.add(project) + try: + session.flush() + break + except IntegrityError: + session.rollback() + account = session.get(Account, account.id) + if account is None: + raise HTTPException(401, "authentication required") from None + else: + raise HTTPException(409, "could not allocate a project slug; retry") key = f"projects/{project.id}/versions/1.json" object_storage.put(key, content.encode(), "application/json") session.add(Version(project_id=project.id, number=1, object_key=key, created_at=timestamp)) diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py index 09d319598b..6f149bad6a 100644 --- a/backend/geolibre_server_api/tests/test_api.py +++ b/backend/geolibre_server_api/tests/test_api.py @@ -160,6 +160,19 @@ def test_username_length_is_enforced(client): ) +def test_oversized_body_is_rejected_before_parsing(client, monkeypatch): + """A declared Content-Length past the ceiling is refused up front, so the JSON + `content` routes cannot have the whole payload materialized before the check.""" + owner = account(client) + huge = "x" * (101 * 1024 * 1024) + response = client.post( + "/api/projects", + headers=auth(owner), + json={"filename": "big.geolibre.json", "content": huge, "visibility": "public"}, + ) + assert response.status_code == 413 + + def test_thumbnail_fork_and_slug_collision(client): owner = account(client) recipient = account(client, "grace") diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index 4c3798ea8a..bd7e2ea718 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -346,14 +346,11 @@ export function createRelay(options: RelayOptions = {}): { } if (message.type === "comment-mutation") { - if (!participantCanEdit(participant, persisted.mode)) { - send(peer, { - type: "error", - code: "forbidden", - message: "You are in view-only mode and cannot comment.", - }); - return; - } + // Shape is validated before the permission check, matching the Worker + // (workers/collab/src/session.ts). Checking permission first made a + // malformed frame from a view-only guest answer `forbidden` here and + // `bad-message` there -- an observable difference between two relays this + // package exists to keep in lockstep. const action = message.action; if (!action || typeof action !== "object") { send(peer, { @@ -399,6 +396,14 @@ export function createRelay(options: RelayOptions = {}): { }); return; } + if (!participantCanEdit(participant, persisted.mode)) { + send(peer, { + type: "error", + code: "forbidden", + message: "You are in view-only mode and cannot comment.", + }); + return; + } const now = Date.now(); if ( participant.lastCommentTs !== undefined && @@ -466,6 +471,19 @@ export function createRelay(options: RelayOptions = {}): { } else { project.comments = comments.filter((comment) => comment.id !== sanitized.commentId); } + // The Worker bounds the mutated project the same way before persisting it + // (workers/collab/src/session.ts). Without this the per-comment caps still + // permit a worst case orders of magnitude past the snapshot ceiling, so + // the Node relay would store and broadcast a project the Worker would have + // refused. + if (ENCODER.encode(JSON.stringify(project)).length > maxSnapshotBytes) { + send(peer, { + type: "error", + code: "bad-message", + message: "Project is too large to store this comment.", + }); + return; + } store.saveProjectState(id, project); broadcast(session, { type: "comment-mutation", action: sanitized }, peer); } diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index 585bf57013..1eaa73c866 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -152,6 +152,55 @@ describe("Node collaboration relay", () => { guest.close(); }); + it("validates comment mutations before permissions and bounds the stored project", async () => { + // Both behaviours are Worker parity requirements: a malformed frame must + // answer bad-message even from a view-only guest, and a comment must not be + // able to push the persisted project past the snapshot ceiling. + const { http } = await start({ maxSnapshotBytes: 400 }); + const created = await createSession(http); + const host = await connect(http, created.sessionId); + const guest = await connect(http, created.sessionId); + await joinSession(host, created.hostToken); + await joinSession(guest); + + host.send(JSON.stringify({ type: "set-mode", mode: "view-only" })); + await next(guest, "mode"); + + // Shape is checked first, so this is bad-message rather than forbidden. + guest.send(JSON.stringify({ type: "comment-mutation", action: { type: "nonsense" } })); + assert.equal((await next(guest, "error")).code, "bad-message"); + + // A well-formed frame from the same view-only guest is the forbidden case. + guest.send( + JSON.stringify({ + type: "comment-mutation", + action: { type: "delete", commentId: "abc" }, + }), + ); + assert.equal((await next(guest, "error")).code, "forbidden"); + + // The host may comment, but not past the configured ceiling. + host.send( + JSON.stringify({ + type: "comment-mutation", + action: { + type: "add", + comment: { + id: "c1", + body: "x".repeat(500), + author: "Host", + authorColor: "#123456", + createdAt: Date.now(), + anchor: { type: "point", lng: 1, lat: 2 }, + }, + }, + }), + ); + assert.equal((await next(host, "error")).code, "bad-message"); + host.close(); + guest.close(); + }); + it("restores the latest snapshot and revision from SQLite after restart", async () => { const directory = await mkdtemp(join(tmpdir(), "geolibre-collab-")); const dbPath = join(directory, "relay.sqlite"); From da41e9b32c82603a0d097a104c2b1ee496442d9f Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 23:14:31 -0400 Subject: [PATCH 06/11] Address CodeRabbit review feedback - Size the Content-Length ceiling for worst-case JSON escaping. parse_content bounds the decoded string, and JSON may spend six wire bytes on one ASCII byte via \u00XX, so the 2x slack would have refused valid uploads at the documented limit before they were ever parsed. - Stop allocating a 101 MiB payload in the oversized-body test. The middleware decides from the header, so the request now carries two bytes and only claims to be huge. Drops the unused monkeypatch argument (ARG001). - Add a test that a fully \u-escaped document within the decoded limit is accepted, with the limits shrunk so the multiplier is what decides the outcome; it fails against the previous 2x ceiling. --- .../geolibre_server_api/main.py | 9 ++-- backend/geolibre_server_api/tests/test_api.py | 54 ++++++++++++++++--- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index 3de6541ddf..f475437bea 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -302,9 +302,12 @@ def _enable_foreign_keys(dbapi_connection, _record): # pragma: no cover - drive # routes let Pydantic materialize the whole payload in memory *before* # parse_content could answer 413 -- the same exposure the thumbnail route # avoids by streaming. The per-route checks stay authoritative; this only - # sheds the obviously-too-big requests early. The slack covers JSON string - # escaping, which can inflate a document well past its decoded size. - body_ceiling = max(max_project_bytes * 2, max_thumbnail_bytes) + 1024 + # sheds the obviously-too-big requests early, so the factor has to be the + # worst case rather than a typical one: parse_content bounds the *decoded* + # string, and JSON may encode any ASCII byte as a six-byte \u00XX escape, so + # a legitimate document at max_project_bytes can be six times that on the + # wire. A tighter bound would 413 valid uploads. + body_ceiling = max(max_project_bytes * 6, max_thumbnail_bytes) + 1024 @app.middleware("http") async def limit_body(request: Request, call_next): diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py index 6f149bad6a..d9d4a32d00 100644 --- a/backend/geolibre_server_api/tests/test_api.py +++ b/backend/geolibre_server_api/tests/test_api.py @@ -160,17 +160,55 @@ def test_username_length_is_enforced(client): ) -def test_oversized_body_is_rejected_before_parsing(client, monkeypatch): +def test_oversized_body_is_rejected_before_parsing(client): """A declared Content-Length past the ceiling is refused up front, so the JSON - `content` routes cannot have the whole payload materialized before the check.""" + `content` routes cannot have the whole payload materialized before the check. + + The request carries a two-byte body and only *claims* to be huge: the + middleware decides from the header alone, so allocating the real payload here + would prove nothing and cost hundreds of MiB in the test process. + """ owner = account(client) - huge = "x" * (101 * 1024 * 1024) - response = client.post( - "/api/projects", - headers=auth(owner), - json={"filename": "big.geolibre.json", "content": huge, "visibility": "public"}, + declared = str(1024 * 1024 * 1024) + headers = {**auth(owner), "content-type": "application/json", "content-length": declared} + request = client.build_request("POST", "/api/projects", headers=headers, content=b"{}") + assert request.headers["content-length"] == declared + assert client.send(request).status_code == 413 + + +def test_fully_escaped_json_within_the_limit_is_accepted(tmp_path, monkeypatch): + """`parse_content` bounds the *decoded* string, but JSON may spend six wire + bytes on one ASCII byte. The header ceiling has to allow for that, or a valid + upload is refused before it is ever parsed. + + Limits are shrunk here so the factor is what decides the outcome: the wire + body below lands above a 2x ceiling and under a 6x one, so this fails if the + multiplier regresses. + """ + monkeypatch.setenv("GEOLIBRE_MAX_PROJECT_BYTES", "1000") + monkeypatch.setenv("GEOLIBRE_MAX_THUMBNAIL_BYTES", "1000") + app = create_app( + f"sqlite:///{tmp_path / 'esc.db'}", + public_url="https://share.example", + storage=FileStorage(str(tmp_path / "objects")), ) - assert response.status_code == 413 + with TestClient(app) as escaped_client: + token = account(escaped_client) + # Padding rides on an unvalidated field; `title` is separately capped at 100. + document = json.dumps( + {"version": "1.0", "title": "Escaped", "layers": [], "note": "p" * 600} + ) + assert len(document.encode()) <= 1000 + # Every character spelled as \u00XX, which is what the ceiling must absorb. + wire = "".join(f"\\u{ord(c):04x}" for c in document) + body = '{"filename":"escaped.geolibre.json","visibility":"public","content":"' + wire + '"}' + assert 1000 * 2 + 1024 < len(body.encode()) < 1000 * 6 + 1024 + response = escaped_client.post( + "/api/projects", + headers={**auth(token), "content-type": "application/json"}, + content=body.encode(), + ) + assert response.status_code == 201, response.text def test_thumbnail_fork_and_slug_collision(client): From fc7592c5023beb6d4996219c3dbd2ac1de737725 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 23:27:37 -0400 Subject: [PATCH 07/11] Address CodeRabbit review feedback - Reject an oversized POST /sessions body on the declared Content-Length before registering the body handlers, so a request claiming a large body cannot hold the connection open while it uploads slowly. The running byte count stays for clients that omit or understate the header, and both paths now share one MAX_SESSION_BODY_BYTES constant. - Add the integration test that was asked for. It writes the request over a raw socket, because fetch refuses to send fewer bytes than it declared, and that mismatch is exactly the case under test; without the guard it times out waiting for a response instead of getting an immediate 413. This was an outside-diff-range comment in the review body rather than an inline thread, which is why it was missed in the earlier rounds. --- workers/collab-node/src/server.ts | 15 ++++++++- workers/collab-node/test/relay.test.ts | 44 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index bd7e2ea718..aa662292c4 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -37,6 +37,9 @@ import { SessionStore, type StoredSession } from "./store.js"; const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const CODE_LENGTH = 8; +/** Cap on the `POST /sessions` request body; the payload is a tiny JSON object. */ +const MAX_SESSION_BODY_BYTES = 16_384; + const DEFAULT_IDLE_TTL_MS = 2 * 60 * 60 * 1000; const ENCODER = new TextEncoder(); @@ -495,6 +498,16 @@ export function createRelay(options: RelayOptions = {}): { if ((url.pathname === "/" || url.pathname === "/health") && request.method === "GET") return json(response, 200, { ok: true, service: "geolibre-collab" }); if (url.pathname === "/sessions" && request.method === "POST") { + // Refuse on the declared length before any handler is registered, so an + // oversized upload cannot hold the connection open while it trickles in. + // The byte counter below still runs, for clients that omit or understate + // the header. + const declaredLength = Number(request.headers["content-length"]); + if (Number.isFinite(declaredLength) && declaredLength > MAX_SESSION_BODY_BYTES) { + request.pause(); + response.on("finish", () => request.destroy()); + return json(response, 413, { error: "Request body too large." }); + } let raw = ""; let rawBytes = 0; let aborted = false; @@ -510,7 +523,7 @@ export function createRelay(options: RelayOptions = {}): { // append: testing the running length first would accept one final chunk // of any size once the total was still under the cap. rawBytes += Buffer.byteLength(chunk); - if (rawBytes <= 16_384) { + if (rawBytes <= MAX_SESSION_BODY_BYTES) { raw += chunk; return; } diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index 1eaa73c866..759ca4b702 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; +import { connect as connectTcp } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, it } from "node:test"; @@ -92,6 +93,49 @@ describe("Node collaboration relay", () => { await assert.rejects(connect(http, "NOTFOUND"), /Unexpected server response: 404/); }); + it("rejects an oversized session-create body by declared length and by count", async () => { + const { http } = await start(); + + // Declared length: answered before any body handler runs. Written over a raw + // socket on purpose -- fetch refuses to send fewer bytes than it declared, + // and sending only two of a claimed million is exactly the case that must + // not be allowed to hold the connection open. + const { port } = new URL(http); + const declared = await new Promise((resolve, reject) => { + const socket = connectTcp({ port: Number(port), host: "127.0.0.1" }, () => { + socket.write( + "POST /sessions HTTP/1.1\r\nHost: localhost\r\n" + + "Content-Type: application/json\r\nContent-Length: 999999\r\n\r\n{}", + ); + }); + let received = ""; + socket.on("data", (chunk) => { + received += chunk.toString(); + if (received.includes("Request body too large")) { + socket.destroy(); + resolve(received); + } + }); + socket.on("error", reject); + socket.setTimeout(5000, () => { + socket.destroy(); + reject(new Error(`no response before the declared body arrived: ${received}`)); + }); + }); + assert.match(declared, /^HTTP\/1\.1 413 /); + + // No declared length to trust: the running byte count still stops it. + const counted = await fetch(`${http}/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"mode":"${"x".repeat(20_000)}"}`, + }); + assert.equal(counted.status, 413); + + // A normal body is unaffected. + assert.equal((await fetch(`${http}/sessions`, { method: "POST" })).status, 200); + }); + it("enforces view-only mode and lets only the host change it", async () => { const { http } = await start(); const created = await createSession(http, "view-only"); From 63a2c8e18117bf7e197f8df341ee5ad44734ea9a Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 23:39:34 -0400 Subject: [PATCH 08/11] Address Claude review feedback - Translate an IntegrityError on account creation into the documented 409. The uniqueness check and the commit are not atomic, and no IntegrityError handler is registered, so the loser of a race escaped as a raw 500. - Eager-load owner and versions on the two listing queries. project_json reads both lazily, so a page fired one query per row; measured 28 statements for 25 projects before, 4 after. - Hash on a dummy password when the account is not found, so login costs the same either way. Short-circuiting skipped scrypt for an unknown username, which enumerates accounts by response time regardless of a request-count limiter. - Handle SIGTERM and SIGINT in the relay entrypoint. Nothing outside the tests called close(), so docker stop killed the process outright and the graceful shutdown path never ran in production. - Hoist the stored-chat validators into @geolibre/collab-core and use them in both relays. The Node store only JSON-parsed the column, so a corrupt record would reach joiners and crash a client on coordinate.lat.toFixed; the Worker had guarded this alone. Covered in the conformance suite. - Warn that POSTGRES_PASSWORD is substituted into a DSN verbatim. Verified the two characters that actually break: "@" reparses the host, and "%" silently percent-decodes to a different password. "/" and ":" are fine, so the note names only what genuinely fails. --- .../geolibre_server_api/main.py | 37 ++++++++++++-- docker-compose.yml | 7 +++ docs/getting-started.md | 14 +++++- packages/collab-core/src/session.ts | 40 ++++++++++++++++ tests/collab-core-conformance.test.ts | 31 ++++++++++++ workers/collab-node/src/server.ts | 15 ++++++ workers/collab-node/src/store.ts | 6 ++- workers/collab/src/session.ts | 48 +------------------ 8 files changed, 145 insertions(+), 53 deletions(-) diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index f475437bea..998f7b770d 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -39,6 +39,7 @@ Session, mapped_column, relationship, + selectinload, sessionmaker, ) @@ -108,6 +109,12 @@ class Version(Base): project: Mapped[Project] = relationship(back_populates="versions") +# project_json reads project.owner.username and len(project.versions), both lazy. +# Without these a single listing page (up to 100 rows) fires ~201 queries instead +# of three. +LISTING_EAGER_LOADS = (selectinload(Project.owner), selectinload(Project.versions)) + + class Credentials(BaseModel): # Both endpoints taking this model are unauthenticated, and password_hash # feeds the value straight to scrypt (n=2**14, ~16 MiB per call). Without an @@ -477,13 +484,29 @@ def create_account(body: Credentials, session: Session = Depends(db)): created_at=now(), ) session.add(account) - session.commit() + try: + session.commit() + except IntegrityError: + # The check above and this commit are not atomic, so two requests + # racing for one username can both pass it. Without this the loser + # escapes as a raw 500 (no IntegrityError exception handler is + # registered), contradicting the documented 409 for a uniqueness + # conflict. + session.rollback() + raise HTTPException(409, "username already exists") from None return {"account": account_json(account), "token": issue_token(session, account)} @app.post("/api/auth/token") def login(body: Credentials, session: Session = Depends(db)): account = session.scalar(select(Account).where(Account.username == body.username)) - if account is None or not password_matches(body.password, account.password_hash): + if account is None: + # Hash anyway before failing. Short-circuiting here would skip the + # scrypt call that a real username always pays for, and the timing + # difference enumerates accounts one request at a time, which a + # request-count rate limiter does not address. + password_hash(body.password or "unused") + raise HTTPException(401, "invalid username or password") + if not password_matches(body.password, account.password_hash): raise HTTPException(401, "invalid username or password") return {"account": account_json(account), "token": issue_token(session, account)} @@ -523,7 +546,10 @@ def get_user_projects( if not own: query = query.where(Project.visibility == "public") projects = session.scalars( - query.order_by(Project.updated_at.desc()).offset(offset).limit(limit) + query.options(*LISTING_EAGER_LOADS) + .order_by(Project.updated_at.desc()) + .offset(offset) + .limit(limit) ).all() return {"projects": [project_json(project) for project in projects]} @@ -568,7 +594,10 @@ def list_projects( count.where(Project.featured.is_(True)), ) projects = session.scalars( - query.order_by(Project.updated_at.desc()).offset(offset).limit(limit) + query.options(*LISTING_EAGER_LOADS) + .order_by(Project.updated_at.desc()) + .offset(offset) + .limit(limit) ).all() return { "projects": [project_json(p) for p in projects], diff --git a/docker-compose.yml b/docker-compose.yml index f894d60435..7853735153 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,13 @@ services: ports: - "${GEOLIBRE_SERVER_PORT:-8000}:8000" environment: + # POSTGRES_USER and POSTGRES_PASSWORD are substituted into this DSN + # verbatim, so two characters bite: "@" splits the URL early (p@ssw0rd + # parses as password "p" against host "ssw0rd@postgres") and "%" starts a + # percent-escape (we%20ird silently becomes "we ird"). Getting-started + # tells operators to choose a strong password, which is exactly when this + # happens. Avoid both, or percent-encode them here (%40, %25) while + # POSTGRES_PASSWORD keeps the literal value. GEOLIBRE_DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-geolibre}:${POSTGRES_PASSWORD:-geolibre}@postgres:5432/${POSTGRES_DB:-geolibre}" GEOLIBRE_STORAGE_PATH: /data/objects GEOLIBRE_PUBLIC_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" diff --git a/docs/getting-started.md b/docs/getting-started.md index b966ac72bb..bf798b147f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -322,7 +322,19 @@ container's runtime configuration is populated with those browser-reachable URLs. For a real deployment, set `GEOLIBRE_SHARE_URL`, `GEOLIBRE_COLLAB_URL`, `GEOLIBRE_VIEWER_URL`, and `GEOLIBRE_CORS_ORIGINS` to the public TLS origins before starting Compose. -Change `POSTGRES_PASSWORD` from its development default. +Change `POSTGRES_PASSWORD` from its development default. It is substituted into +a connection URL verbatim, so two characters need care: + +- `@` splits the URL early. `p@ssw0rd` is read as password `p` against host + `ssw0rd@postgres`, and the projects server restarts in a loop on a psycopg + error that never mentions the password. +- `%` starts a percent-escape. `we%20ird` is silently decoded to `we ird`, so + the server authenticates with a password you never set and simply gets + rejected. + +Either avoid both characters or percent-encode the value in the URL (`%40` for +`@`, `%25` for `%`) while leaving `POSTGRES_PASSWORD` itself as the literal +password Postgres should expect. The projects API can also run as one small SQLite-backed container, without Postgres: diff --git a/packages/collab-core/src/session.ts b/packages/collab-core/src/session.ts index 940d6c498e..dbac3fef93 100644 --- a/packages/collab-core/src/session.ts +++ b/packages/collab-core/src/session.ts @@ -1,4 +1,5 @@ import type { + CollabChatMessage, CollabCursor, CollabParticipant, CollabView, @@ -149,3 +150,42 @@ export function sanitizeView(v: unknown): CollabView | null { } return view; } + +/** + * Validate one stored chat entry's field types so a corrupt record cannot reach + * clients, where a bad `coordinate` would crash `coordinate.lat.toFixed`. A + * read-path guard against tampering or a partial write, deliberately not a full + * mirror of the write path. + */ +export function isValidChatMessage(m: unknown): m is CollabChatMessage { + if (!m || typeof m !== "object") return false; + const o = m as Record; + const coord = o.coordinate as Record | null | undefined; + const coordOk = + coord === null || + coord === undefined || + (typeof coord === "object" && finite(coord.lng) && finite(coord.lat)); + return ( + typeof o.id === "string" && + typeof o.clientId === "string" && + typeof o.displayName === "string" && + typeof o.color === "string" && + HEX_COLOR_RE.test(o.color) && + typeof o.text === "string" && + o.text !== "" && + finite(o.ts) && + coordOk + ); +} + +/** Parse a persisted chat log, dropping entries that fail {@link isValidChatMessage}. */ +export function parseStoredChat(raw: unknown): CollabChatMessage[] { + if (typeof raw === "string") { + try { + raw = JSON.parse(raw); + } catch { + return []; + } + } + return Array.isArray(raw) ? raw.filter(isValidChatMessage) : []; +} diff --git a/tests/collab-core-conformance.test.ts b/tests/collab-core-conformance.test.ts index bd64aa5a16..67bc20e7c8 100644 --- a/tests/collab-core-conformance.test.ts +++ b/tests/collab-core-conformance.test.ts @@ -6,6 +6,7 @@ import { clearParticipantOverrides, MAX_SNAPSHOT_BYTES, normalizeMode, + parseStoredChat, participantCanEdit, sanitizeColor, sanitizeCursor, @@ -87,6 +88,36 @@ describe("collaboration relay conformance", () => { if (!custom.ok) assert.equal(custom.code, "too-large"); }); + it("drops corrupt entries when reading a persisted chat log", () => { + // Both relays read chat back out of storage, so this guard belongs to the + // shared core: a tampered or partially written record must not reach a + // joiner, where a bad coordinate crashes `coordinate.lat.toFixed`. + const good = { + id: "1", + clientId: "c", + displayName: "Ada", + color: "#123456", + text: "hello", + ts: 1, + coordinate: null, + }; + assert.deepEqual(parseStoredChat(JSON.stringify([good])), [good]); + assert.deepEqual(parseStoredChat("not json"), []); + assert.deepEqual(parseStoredChat(JSON.stringify({ not: "an array" })), []); + assert.deepEqual(parseStoredChat(undefined), []); + for (const bad of [ + { ...good, color: "red" }, + { ...good, text: "" }, + { ...good, ts: Number.NaN }, + { ...good, coordinate: { lng: "x", lat: 1 } }, + { ...good, id: 7 }, + ]) { + assert.deepEqual(parseStoredChat(JSON.stringify([bad])), [], JSON.stringify(bad)); + } + // A corrupt neighbour does not take the valid entries with it. + assert.deepEqual(parseStoredChat(JSON.stringify([good, { ...good, text: "" }])), [good]); + }); + it("sanitizes untrusted presence viewports", () => { // Both relays run sanitizeView over presence frames, so its contract belongs // in the shared suite alongside the permission rules. diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index aa662292c4..ce0c6dcb6b 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -638,4 +638,19 @@ if (isMain) { relay.server.listen(port, process.env.HOST ?? "0.0.0.0", () => { console.log(`GeoLibre collaboration relay listening on port ${port}`); }); + // Without these, `docker stop` (and a Kubernetes pod eviction) terminates the + // process on the default SIGTERM action, so relay.close() never runs: peers + // get a raw socket drop instead of a 1001 close, and the SQLite handle is not + // closed cleanly. Nothing outside the tests called close() before this. + let closing = false; + for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + if (closing) return; + closing = true; + relay.close().then( + () => process.exit(0), + () => process.exit(1), + ); + }); + } } diff --git a/workers/collab-node/src/store.ts b/workers/collab-node/src/store.ts index 990c8a7ec4..4b429343ea 100644 --- a/workers/collab-node/src/store.ts +++ b/workers/collab-node/src/store.ts @@ -1,6 +1,7 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { parseStoredChat } from "@geolibre/collab-core"; import type { CollabChatMessage, CollaborationMode } from "@geolibre/collab-core"; export interface StoredSession { @@ -74,7 +75,10 @@ export class SessionStore { mode: row.mode === "view-only" ? "view-only" : "co-edit", rev: row.rev, snapshot: parseJson(row.snapshot, null), - chat: parseJson(row.chat, []), + // Shape-checked per entry, not just JSON-parsed: a corrupt or tampered + // chat column would otherwise reach joiners in `welcome` and crash a + // client on `coordinate.lat.toFixed`. Same guard the Worker applies. + chat: parseStoredChat(row.chat), updatedAt: row.updated_at, }; } diff --git a/workers/collab/src/session.ts b/workers/collab/src/session.ts index 98713e4e89..4886c89d05 100644 --- a/workers/collab/src/session.ts +++ b/workers/collab/src/session.ts @@ -26,6 +26,7 @@ import { MAX_CHAT_STORAGE_BYTES, MAX_CHAT_TEXT_LENGTH, MAX_SNAPSHOT_BYTES, + parseStoredChat, MIN_CHAT_INTERVAL_MS, normalizeMode, participantCanEdit, @@ -38,14 +39,6 @@ import { type SessionParticipant, } from "@geolibre/collab-core"; -function finite(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n); -} - -// Accepted participant color: a 3- or 6-digit hex. Shared by the join path and -// the stored-chat validator so both enforce the same shape. -const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - /** Parse the stored snapshot defensively: a corrupt value yields null rather * than throwing (which would lock joiners out of the session). */ function parseStoredSnapshot(snapshot: string | undefined): unknown { @@ -81,45 +74,6 @@ const ENCODER = new TextEncoder(); */ type SocketAttachment = SessionParticipant; -/** Validate a single stored chat entry's field types so a corrupt record can't - * reach clients (where it would, e.g., crash `coordinate.lat.toFixed`). */ -function isValidChatMessage(m: unknown): m is CollabChatMessage { - if (!m || typeof m !== "object") return false; - const o = m as Record; - const coord = o.coordinate as Record | null | undefined; - const coordOk = - coord === null || - coord === undefined || - (typeof coord === "object" && finite(coord.lng) && finite(coord.lat)); - return ( - typeof o.id === "string" && - typeof o.clientId === "string" && - typeof o.displayName === "string" && - // Reject records whose field types/shapes would crash or mislead a client: - // a non-hex color, a blank body, a non-finite timestamp, or a bad coordinate - // (which would crash `coordinate.lat.toFixed`). Not a full write-path mirror. - typeof o.color === "string" && - HEX_COLOR_RE.test(o.color) && - typeof o.text === "string" && - o.text !== "" && - finite(o.ts) && - coordOk - ); -} - -/** Parse the stored chat log defensively; a corrupt value yields an empty log - * (rather than throwing, which would lock joiners out of the welcome) and any - * malformed individual entries are dropped. */ -function parseStoredChat(raw: string | undefined): CollabChatMessage[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(isValidChatMessage) : []; - } catch { - return []; - } -} - type PresenceState = PresenceEntry; /** From ee5544b1203b445075eeedd5a4c556130af245ec Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 23:48:48 -0400 Subject: [PATCH 09/11] Address CodeRabbit review feedback - Register CORSMiddleware after limit_body so it stays outermost. Starlette wraps in reverse registration order, so the 413 was returned without CORS headers and a browser could not read the documented error body. Confirmed before and after, and pinned in the test. - Pin the geolibre uid/gid to 1000, which the documented volume-repair chown assumes; the base image merely happened to allocate it. - Require POSTGRES_PASSWORD instead of committing a shared default for the account that owns all project metadata, and document that Postgres only applies it at volume initialization, so rotating it later needs ALTER USER or a fresh volume. - Document binding the projects server and relay to loopback behind a proxy, with an override file; pointing the browser URLs at a proxy does not stop direct access to those listeners. - Deduplicate reply ids in validateComment. The relay's reply action already skipped a duplicate id, leaving inline replies as the one path that could persist two replies sharing an id. - Use COPY --chown in the relay image instead of a recursive chown of /app, which rewrote node_modules into a second layer. - Declare engines.node >=22.13, below which node:sqlite is flag-gated. - Reuse normalizeMode for the stored mode rather than an inline ternary that would silently downgrade a future third mode. - Document GEOLIBRE_HOST and GEOLIBRE_PORT, and that the fork request body is optional. - Pin the override-specific refusal message in the conformance suite, and correct two comments in the Worker that described code the extraction removed. --- backend/geolibre_server_api/Dockerfile | 6 +++- backend/geolibre_server_api/README.md | 3 ++ .../geolibre_server_api/main.py | 34 ++++++++++-------- backend/geolibre_server_api/tests/test_api.py | 13 +++++-- docker-compose.yml | 6 ++-- docs/getting-started.md | 35 +++++++++++++++++-- docs/server-api.md | 6 ++-- packages/collab-core/src/comment-validate.ts | 9 ++++- tests/collab-core-conformance.test.ts | 9 ++++- workers/collab-node/Dockerfile | 10 +++--- workers/collab-node/package.json | 3 ++ workers/collab-node/src/store.ts | 7 ++-- workers/collab/src/session.ts | 11 +++--- 13 files changed, 114 insertions(+), 38 deletions(-) diff --git a/backend/geolibre_server_api/Dockerfile b/backend/geolibre_server_api/Dockerfile index 1ccb193fe0..46260c9cde 100644 --- a/backend/geolibre_server_api/Dockerfile +++ b/backend/geolibre_server_api/Dockerfile @@ -9,7 +9,11 @@ RUN pip install --no-cache-dir ".[postgres,s3]" # mounted at /data/objects inherits the image's ownership only when that exact # path already exists; otherwise Docker creates the mountpoint as root and the # unprivileged user below cannot write projects into it. -RUN useradd --create-home geolibre \ +# uid/gid pinned to 1000 rather than taking the next free id: README.md documents +# `chown -R 1000:1000` to repair a volume created by an earlier root-owned image, +# and that instruction is only correct while this user keeps that id. +RUN groupadd --gid 1000 geolibre \ + && useradd --uid 1000 --gid 1000 --create-home geolibre \ && mkdir -p /data/objects \ && chown -R geolibre:geolibre /data USER geolibre diff --git a/backend/geolibre_server_api/README.md b/backend/geolibre_server_api/README.md index c25695b695..c324e7005e 100644 --- a/backend/geolibre_server_api/README.md +++ b/backend/geolibre_server_api/README.md @@ -21,6 +21,9 @@ Configuration: - `GEOLIBRE_VIEWER_URL`: GeoLibre viewer origin. - `GEOLIBRE_CORS_ORIGINS`: comma-separated web origins, default `*`. - `GEOLIBRE_MAX_PROJECT_BYTES`, `GEOLIBRE_MAX_THUMBNAIL_BYTES`: upload limits. +- `GEOLIBRE_HOST`, `GEOLIBRE_PORT`: bind address and port for the + `geolibre-server-api` entry point, default `0.0.0.0` and `8000`. Bind to + `127.0.0.1` when a reverse proxy fronts the service. ## Volume ownership diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index 998f7b770d..263b4a4cde 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -289,21 +289,6 @@ def _enable_foreign_keys(dbapi_connection, _record): # pragma: no cover - drive app = FastAPI(title="GeoLibre projects and identity API", version="1.0") app.state.engine = engine app.state.storage = object_storage - origins = [x.strip() for x in os.getenv("GEOLIBRE_CORS_ORIGINS", "*").split(",") if x.strip()] - # CORSMiddleware treats a "*" anywhere in the list as allow-all, so a value - # like "*,https://app.example" would otherwise pair allow-all with - # allow_credentials=True (the list is not exactly ["*"]) and accept - # credentialed requests from any origin. Wildcard wins, and drops credentials - # with it. - wildcard = "*" in origins - app.add_middleware( - CORSMiddleware, - allow_origins=["*"] if wildcard else origins, - allow_credentials=not wildcard, - allow_methods=["*"], - allow_headers=["Authorization", "Content-Type"], - ) - # A declared Content-Length past the largest thing any route accepts is # rejected before the body is read at all. Without this, the JSON `content` # routes let Pydantic materialize the whole payload in memory *before* @@ -323,6 +308,25 @@ async def limit_body(request: Request, call_next): return JSONResponse({"error": "request body too large"}, status_code=413) return await call_next(request) + origins = [x.strip() for x in os.getenv("GEOLIBRE_CORS_ORIGINS", "*").split(",") if x.strip()] + # CORSMiddleware treats a "*" anywhere in the list as allow-all, so a value + # like "*,https://app.example" would otherwise pair allow-all with + # allow_credentials=True (the list is not exactly ["*"]) and accept + # credentialed requests from any origin. Wildcard wins, and drops credentials + # with it. + wildcard = "*" in origins + # Registered last so it is the outermost layer: Starlette wraps in reverse + # order of registration, and with limit_body outermost its 413 returned + # without CORS headers, leaving a browser unable to read the documented + # error body. + app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if wildcard else origins, + allow_credentials=not wildcard, + allow_methods=["*"], + allow_headers=["Authorization", "Content-Type"], + ) + @app.get("/health") def health(): return {"ok": True, "service": "geolibre-server"} diff --git a/backend/geolibre_server_api/tests/test_api.py b/backend/geolibre_server_api/tests/test_api.py index d9d4a32d00..99e0b368f7 100644 --- a/backend/geolibre_server_api/tests/test_api.py +++ b/backend/geolibre_server_api/tests/test_api.py @@ -170,10 +170,19 @@ def test_oversized_body_is_rejected_before_parsing(client): """ owner = account(client) declared = str(1024 * 1024 * 1024) - headers = {**auth(owner), "content-type": "application/json", "content-length": declared} + headers = { + **auth(owner), + "content-type": "application/json", + "content-length": declared, + "origin": "https://app.example", + } request = client.build_request("POST", "/api/projects", headers=headers, content=b"{}") assert request.headers["content-length"] == declared - assert client.send(request).status_code == 413 + response = client.send(request) + assert response.status_code == 413 + # The rejection must still pass back out through CORSMiddleware, or a browser + # cannot read the error body it just received. + assert response.headers.get("access-control-allow-origin") is not None def test_fully_escaped_json_within_the_limit_is_accepted(tmp_path, monkeypatch): diff --git a/docker-compose.yml b/docker-compose.yml index 7853735153..62aca5e98e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: # tells operators to choose a strong password, which is exactly when this # happens. Avoid both, or percent-encode them here (%40, %25) while # POSTGRES_PASSWORD keeps the literal value. - GEOLIBRE_DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-geolibre}:${POSTGRES_PASSWORD:-geolibre}@postgres:5432/${POSTGRES_DB:-geolibre}" + GEOLIBRE_DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-geolibre}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD (see docs/getting-started.md)}@postgres:5432/${POSTGRES_DB:-geolibre}" GEOLIBRE_STORAGE_PATH: /data/objects GEOLIBRE_PUBLIC_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" GEOLIBRE_VIEWER_URL: "${GEOLIBRE_VIEWER_URL:-http://localhost:8080}" @@ -90,7 +90,9 @@ services: image: postgres:17-alpine environment: POSTGRES_USER: "${POSTGRES_USER:-geolibre}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-geolibre}" + # Required, not defaulted: this account owns all project metadata, and a + # password committed to the repository is the same on every deployment. + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD (see docs/getting-started.md)}" POSTGRES_DB: "${POSTGRES_DB:-geolibre}" volumes: - geolibre-postgres:/var/lib/postgresql/data diff --git a/docs/getting-started.md b/docs/getting-started.md index bf798b147f..11a84ebbfd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -313,17 +313,46 @@ repository root, start the web app, projects server, Node collaboration relay, and Postgres together: ```bash -docker compose up --build +POSTGRES_PASSWORD=choose-a-password docker compose up --build ``` +`POSTGRES_PASSWORD` is required, not defaulted: that account owns all project +metadata, and a password committed to this repository would be identical on +every deployment. Compose stops with an error naming the variable if it is +unset. Set it in your shell, an `.env` file next to `docker-compose.yml`, or +your orchestrator's secret store. + +Postgres only applies this password when it initializes its data directory, so +changing it later does **not** change the password on an existing volume: the +projects server then fails authentication against a database that still expects +the old one. To rotate it, either `ALTER USER` inside the running database or +recreate the volume. + Open `http://localhost:8080`. The projects API is exposed at `http://localhost:8000` and the relay at `ws://localhost:8787`; the web container's runtime configuration is populated with those browser-reachable URLs. For a real deployment, set `GEOLIBRE_SHARE_URL`, `GEOLIBRE_COLLAB_URL`, `GEOLIBRE_VIEWER_URL`, and `GEOLIBRE_CORS_ORIGINS` to the public TLS origins before starting Compose. -Change `POSTGRES_PASSWORD` from its development default. It is substituted into -a connection URL verbatim, so two characters need care: + +Behind a reverse proxy, only the web container should be reachable from outside +the host. The Compose file publishes the projects server on `8000` and the relay +on `8787` for local use, and pointing the browser URLs at your proxy does not +stop anyone connecting to those listeners directly. Bind them to loopback (or +drop the mappings entirely and let the proxy reach them over the Compose +network) with an override file: + +```yaml +# docker-compose.override.yml +services: + geolibre-server: + ports: ["127.0.0.1:8000:8000"] + geolibre-collab: + ports: ["127.0.0.1:8787:8787"] +``` + +The password is substituted into a connection URL verbatim, so two characters +need care: - `@` splits the URL early. `p@ssw0rd` is read as password `p` against host `ssw0rd@postgres`, and the projects server restarts in a loop on a psycopg diff --git a/docs/server-api.md b/docs/server-api.md index 9e4f71584d..c28eeba0fa 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -235,8 +235,10 @@ Requires ownership. Deletes metadata and stored objects. Response: `204`. ### `POST /api/projects/{id}/forks` Requires auth. Creates a new project owned by the caller from the visible -source's latest content. It accepts `{"visibility":"private"}` (default -`private`) and returns `201` with `{"project": }`. The source +source's latest content. The request body is **optional**: `{"visibility": ...}` +selects the fork's visibility, and omitting the body entirely (the common "fork +this project" call) must behave as `{"visibility":"private"}` rather than +returning `422`. Responds `201` with `{"project": }`. The source `forkCount` increases atomically. ### Raw project and website-compatible routes diff --git a/packages/collab-core/src/comment-validate.ts b/packages/collab-core/src/comment-validate.ts index 1b65e58f42..e43ee05f44 100644 --- a/packages/collab-core/src/comment-validate.ts +++ b/packages/collab-core/src/comment-validate.ts @@ -150,10 +150,17 @@ export function validateComment(raw: unknown): ValidatedComment | null { : new Date().toISOString(); const replies: ValidatedReply[] = []; + // Ids deduplicated here as well as in the relay's `reply` action, which already + // skips a reply whose id exists. Inline replies on an incoming comment were the + // one path that could persist two replies sharing an id, which peers then + // render as duplicate keys. + const replyIds = new Set(); if (Array.isArray(o.replies)) { for (const r of o.replies.slice(0, MAX_REPLIES_PER_COMMENT)) { const validated = validateReply(r); - if (validated) replies.push(validated); + if (!validated || replyIds.has(validated.id)) continue; + replyIds.add(validated.id); + replies.push(validated); } } diff --git a/tests/collab-core-conformance.test.ts b/tests/collab-core-conformance.test.ts index 67bc20e7c8..63c410d0e7 100644 --- a/tests/collab-core-conformance.test.ts +++ b/tests/collab-core-conformance.test.ts @@ -48,7 +48,14 @@ describe("collaboration relay conformance", () => { guest.editOverride = false; assert.equal(participantCanEdit(guest, "co-edit"), false); - assert.equal(authorizeSnapshot(guest, "co-edit", 20).ok, false); + // The message differs from the session-level refusal and is shown to the + // user by both relays, so pin it: asserting only `ok === false` would let a + // regression swap in the generic "This session is view-only." text. + assert.deepEqual(authorizeSnapshot(guest, "co-edit", 20), { + ok: false, + code: "forbidden", + message: "The host has set you to view-only.", + }); }); it("gates set-mode and set-participant-mode to the host", () => { diff --git a/workers/collab-node/Dockerfile b/workers/collab-node/Dockerfile index f1be05ad7d..8278a63388 100644 --- a/workers/collab-node/Dockerfile +++ b/workers/collab-node/Dockerfile @@ -11,13 +11,15 @@ RUN npm run build -w geolibre-collab-node FROM node:22-bookworm-slim ENV NODE_ENV=production PORT=8787 COLLAB_DB_PATH=/data/collab.sqlite WORKDIR /app -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/workers/collab-node/package.json ./workers/collab-node/package.json -COPY --from=build /app/workers/collab-node/dist ./workers/collab-node/dist +# --chown on the COPY rather than a recursive chown afterwards: chowning /app +# rewrites every file, node_modules included, into a second copy in a new layer. +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/workers/collab-node/package.json ./workers/collab-node/package.json +COPY --from=build --chown=node:node /app/workers/collab-node/dist ./workers/collab-node/dist # /data is created and owned here so a named volume mounted over it inherits the # ownership; Docker only copies image ownership onto a volume when the path # already exists, otherwise the mountpoint lands root-owned. -RUN mkdir -p /data && chown -R node:node /data /app +RUN mkdir -p /data && chown node:node /data VOLUME ["/data"] EXPOSE 8787 USER node diff --git a/workers/collab-node/package.json b/workers/collab-node/package.json index fd22e4e47f..ab4df2bc7b 100644 --- a/workers/collab-node/package.json +++ b/workers/collab-node/package.json @@ -16,5 +16,8 @@ "devDependencies": { "@types/ws": "^8.18.1", "esbuild": "^0.27.0" + }, + "engines": { + "node": ">=22.13" } } diff --git a/workers/collab-node/src/store.ts b/workers/collab-node/src/store.ts index 4b429343ea..0b50d728c3 100644 --- a/workers/collab-node/src/store.ts +++ b/workers/collab-node/src/store.ts @@ -1,7 +1,7 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { parseStoredChat } from "@geolibre/collab-core"; +import { normalizeMode, parseStoredChat } from "@geolibre/collab-core"; import type { CollabChatMessage, CollaborationMode } from "@geolibre/collab-core"; export interface StoredSession { @@ -72,7 +72,10 @@ export class SessionStore { return { id: row.id, hostToken: row.host_token, - mode: row.mode === "view-only" ? "view-only" : "co-edit", + // normalizeMode rather than an inline ternary: if the shared contract + // gains a third mode, this would silently downgrade it to co-edit while + // the relay accepted it on the wire. + mode: normalizeMode(row.mode), rev: row.rev, snapshot: parseJson(row.snapshot, null), // Shape-checked per entry, not just JSON-parsed: a corrupt or tampered diff --git a/workers/collab/src/session.ts b/workers/collab/src/session.ts index 4886c89d05..ab0ee8f5cb 100644 --- a/workers/collab/src/session.ts +++ b/workers/collab/src/session.ts @@ -416,8 +416,10 @@ export class CollabSession extends DurableObject { }); return; } - // `message` is untrusted JSON, so guard the lookup key's type (mirrors the - // strict-boolean coercion below) before matching it against attachments. + // `message` is untrusted JSON; setParticipantOverride does the type guard on + // the lookup key and the strict-boolean coercion of `canEdit`, and returns + // false for an unknown or already-disconnected target. That case needs no + // error frame: the disconnect broadcast already reconciles the host's view. const socketsWithAttachments = this.attachedSockets(); const changed = setParticipantOverride( attachment, @@ -426,12 +428,11 @@ export class CollabSession extends DurableObject { message.canEdit, ); if (!changed) return; + // `changed` implies the entry is in this same snapshot -- setParticipantOverride + // found and mutated it, with no await in between -- so this always resolves. const target = socketsWithAttachments.find( (entry) => entry.attachment.clientId === message.clientId, ); - // Target disconnected between the host's click and this frame: the - // disconnect already broadcasts an updated roster, so the host's view (and - // the now-absent toggle) reconciles on its own; no error frame needed. if (!target) return; target.socket.serializeAttachment(target.attachment); // Everyone re-derives effective permission from the participants list (the From ab4571c0becabf48b418ca7cf22a0f99fc281ebb Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 3 Aug 2026 23:55:13 -0400 Subject: [PATCH 10/11] Address Claude review feedback - Filter non-object entries out of project.comments and target.replies in the Node relay. Snapshot content is opaque, so a client could plant null with an ordinary snapshot frame and every subsequent .id read threw, leaving commenting permanently broken for that session while the Worker tolerated the same data. Reproduced against the container, then covered in the relay suite. - Attach an error listener to the raw upgrade socket before writing to it. A net.Socket with no listener throws on the next TCP error, which is uncaught and stops the relay, and this is the pre-auth path most exposed to arbitrary traffic. - Retry the slug and version allocations on OperationalError too. On the SQLite default a concurrent writer surfaces as "database is locked" rather than IntegrityError, which the loops did not catch. - Add a catch-all exception handler returning the documented JSON error shape. Only HTTPException and RequestValidationError were registered, so anything else escaped as a plain-text 500. - Document that the Content-Length guard sees only a declared length, so a chunked or HTTP/2 request is still parsed in full, and that request size should also be capped at the proxy. --- .../geolibre_server_api/main.py | 27 +++++++++++-- docs/server-api.md | 13 +++++-- workers/collab-node/src/server.ts | 23 ++++++++--- workers/collab-node/test/relay.test.ts | 38 +++++++++++++++++++ 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index 263b4a4cde..a198721ea9 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -2,6 +2,7 @@ import hashlib import hmac +import logging import json import os import re @@ -32,7 +33,7 @@ select, update, ) -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.orm import ( DeclarativeBase, Mapped, @@ -51,6 +52,8 @@ SLUG_RE = re.compile(r"[^a-z0-9]+") IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"} +logger = logging.getLogger(__name__) + class Base(DeclarativeBase): pass @@ -301,6 +304,11 @@ def _enable_foreign_keys(dbapi_connection, _record): # pragma: no cover - drive # wire. A tighter bound would 413 valid uploads. body_ceiling = max(max_project_bytes * 6, max_thumbnail_bytes) + 1024 + # Known limit: this reads the declared length only, so a chunked or HTTP/2 + # request without Content-Length skips it and is still parsed in full. The + # per-route checks bound what gets *stored* either way; closing the parsing + # cost for those requests needs a streaming body reader, which is why the + # deployment notes put a request-size limit at the proxy. @app.middleware("http") async def limit_body(request: Request, call_next): declared = request.headers.get("content-length") @@ -340,6 +348,15 @@ async def http_error(_request: Request, exc: HTTPException): async def validation_error(_request: Request, exc: RequestValidationError): return JSONResponse({"error": str(exc.errors()[0]["msg"])}, status_code=422) + @app.exception_handler(Exception) + async def unexpected_error(_request: Request, exc: Exception): + # Only HTTPException and RequestValidationError were handled, so anything + # else (a database error, say) escaped as a plain-text 500 and broke the + # documented "errors are JSON objects with an error string" contract. The + # detail is logged rather than returned, so internals are not disclosed. + logger.exception("unhandled error", exc_info=exc) + return JSONResponse({"error": "internal server error"}, status_code=500) + def db(): with sessions() as session: yield session @@ -457,7 +474,10 @@ def create_project( try: session.flush() break - except IntegrityError: + except (IntegrityError, OperationalError): + # OperationalError covers SQLite's "database is locked", which is how + # a concurrent writer usually surfaces on the default deployment; + # it is transient, so it belongs in the retry rather than in a 500. session.rollback() account = session.get(Account, account.id) if account is None: @@ -679,7 +699,8 @@ def update_content( try: session.flush() break - except IntegrityError: + except (IntegrityError, OperationalError): + # See create_project: a SQLite lock is transient and retryable. session.rollback() project = owned(session.get(Project, project_id), account) else: diff --git a/docs/server-api.md b/docs/server-api.md index c28eeba0fa..77eb24663b 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -26,7 +26,7 @@ implementation or storage engine. The reference implementation lives in ## What the reference server leaves to the operator -Two parts of the contract above are deliberately not implemented in +Three parts of the contract above are deliberately not implemented in `backend/geolibre_server_api`, and an operator exposing it publicly has to supply them: @@ -38,9 +38,14 @@ supply them: and by username. - **Token expiry.** `401` covers an expired token, but tokens issued here do not carry an expiry and stay valid until `DELETE /api/auth/token` revokes them. - -Both are contract-level capabilities a compatible server may implement; the -reference implementation is a correctness baseline, not a hardened deployment. +- **A request-size limit.** The server rejects an oversized *declared* + `Content-Length` before reading the body, but a chunked or HTTP/2 request + declares no length and is parsed in full before the per-route limit applies. + Cap request size at the proxy as well. + +All three are contract-level capabilities a compatible server may implement; +the reference implementation is a correctness baseline, not a hardened +deployment. ## Limits diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index ce0c6dcb6b..594c933bf5 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -420,9 +420,15 @@ export function createRelay(options: RelayOptions = {}): { !Array.isArray(persisted.snapshot) ? { ...(persisted.snapshot as Record) } : {}; - const comments = Array.isArray(project.comments) - ? (project.comments as Record[]) - : []; + // Non-object entries are filtered out, not just checked for array-ness. + // Snapshot content is opaque and unvalidated, so a client can plant `null` + // into project.comments with an ordinary snapshot frame; every `.id` read + // below would then throw and leave commenting permanently broken for that + // session. The Worker tolerates the same corrupt data, so this is parity + // as well as robustness. + const comments = (Array.isArray(project.comments) ? project.comments : []).filter( + (entry): entry is Record => !!entry && typeof entry === "object", + ); if (sanitized.type === "add") { const comment = sanitized.comment as Record; if ( @@ -445,9 +451,9 @@ export function createRelay(options: RelayOptions = {}): { send(peer, { type: "error", code: "bad-message", message: "Invalid reply target." }); return; } - const replies = Array.isArray(target.replies) - ? (target.replies as Record[]) - : []; + const replies = (Array.isArray(target.replies) ? target.replies : []).filter( + (entry): entry is Record => !!entry && typeof entry === "object", + ); if (replies.length >= MAX_REPLIES_PER_COMMENT) { send(peer, { type: "error", @@ -563,6 +569,11 @@ export function createRelay(options: RelayOptions = {}): { }); server.on("upgrade", (request: IncomingMessage, socket, head) => { + // A raw net.Socket with no "error" listener throws on the next TCP error, + // which is an uncaught exception that stops the relay. This is the pre-auth + // path, so it takes whatever port scanners and stray health checks send, + // and the reject branch below writes to the socket and destroys it. + socket.on("error", () => socket.destroy()); const url = new URL(request.url ?? "/", "http://localhost"); const match = url.pathname.match(/^\/sessions\/([^/]+)\/ws$/); if (request.method !== "GET" || !match || !store.get(match[1])) { diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index 759ca4b702..6e40d94607 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -245,6 +245,44 @@ describe("Node collaboration relay", () => { guest.close(); }); + it("keeps commenting working after a snapshot plants a null into comments", async () => { + // Snapshot content is opaque and unvalidated, so a client can seed + // project.comments with a non-object. Every `.id` read in the mutation path + // would then throw, leaving commenting permanently broken for the session + // even though the process survives. + const { http } = await start(); + const created = await createSession(http); + const host = await connect(http, created.sessionId); + await joinSession(host, created.hostToken); + // The mutation is broadcast to everyone except its sender, so observe from a + // second peer. + const observer = await connect(http, created.sessionId); + await joinSession(observer); + + host.send(JSON.stringify({ type: "snapshot", project: { comments: [null, 7, "x"] } })); + await new Promise((resolve) => setTimeout(resolve, 50)); + + host.send( + JSON.stringify({ + type: "comment-mutation", + action: { + type: "add", + comment: { + id: "c1", + body: "still works", + author: { name: "Host", color: "#123456" }, + createdAt: new Date().toISOString(), + anchor: { type: "point", lngLat: [1, 2] }, + }, + }, + }), + ); + const echoed = await next(observer, "comment-mutation"); + assert.equal((echoed.action as { type: string }).type, "add"); + observer.close(); + host.close(); + }); + it("restores the latest snapshot and revision from SQLite after restart", async () => { const directory = await mkdtemp(join(tmpdir(), "geolibre-collab-")); const dbPath = join(directory, "relay.sqlite"); From f12b48b07dde2c7cdfb486e695b66a6a07b82191 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:55:35 +0000 Subject: [PATCH 11/11] style: auto-format (ruff + oxfmt) [pre-commit.ci] --- backend/geolibre_server_api/geolibre_server_api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index a198721ea9..1add5875ee 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -2,8 +2,8 @@ import hashlib import hmac -import logging import json +import logging import os import re import secrets