diff --git a/.dockerignore b/.dockerignore index 0fb9fa5068..43c5d67a90 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,15 @@ apps/geolibre-desktop/dist apps/geolibre-desktop/src-tauri backend/geolibre_server/.venv backend/**/__pycache__ -workers +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/.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..46260c9cde --- /dev/null +++ b/backend/geolibre_server_api/Dockerfile @@ -0,0 +1,24 @@ +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]" + +# 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. +# 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 + +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..c324e7005e --- /dev/null +++ b/backend/geolibre_server_api/README.md @@ -0,0 +1,45 @@ +# 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. +- `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 + +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/__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..1add5875ee --- /dev/null +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -0,0 +1,890 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +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, + event, + func, + select, + update, +) +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + Session, + mapped_column, + relationship, + selectinload, + sessionmaker, +) + +Visibility = Literal["public", "unlisted", "private"] +# 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"} + +logger = logging.getLogger(__name__) + + +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") + + +# 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 + # 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): + 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) + 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() + 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 + # 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, 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 + + # 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") + 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) + + 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"} + + @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) + + @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 + + 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() + # 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, 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: + 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)) + 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) + 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: + # 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)} + + @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)): + # 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( + 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.options(*LISTING_EAGER_LOADS) + .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.options(*LISTING_EAGER_LOADS) + .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: + # 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"] 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) + 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) + # 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, OperationalError): + # See create_project: a SQLite lock is transient and retryable. + 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.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, + # 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. 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), + ): + 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 or ForkRequest()).visibility, + commit=False, + ) + # 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)} + + 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") + # 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() + 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) + # 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 body + + @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 + + +def run() -> None: + import uvicorn + + # 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/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..99e0b368f7 --- /dev/null +++ b/backend/geolibre_server_api/tests/test_api.py @@ -0,0 +1,277 @@ +import hashlib +import json + +import pytest +from fastapi.testclient import TestClient +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=FileStorage(str(tmp_path / "objects")), + ) + 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" + 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 + + 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_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_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. + + 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) + declared = str(1024 * 1024 * 1024) + 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 + 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): + """`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")), + ) + 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): + 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 + + # 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 + + +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..62aca5e98e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +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: + # 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:?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}" + 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}" + # 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 + 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/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 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..11a84ebbfd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -308,11 +308,80 @@ 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 +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. + +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 + 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: + +```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..77eb24663b --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,273 @@ +# 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. + +## What the reference server leaves to the operator + +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: + +- **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. +- **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 + +| 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. +- `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 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` + +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}` + +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. 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 + +- `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..e43ee05f44 --- /dev/null +++ b/packages/collab-core/src/comment-validate.ts @@ -0,0 +1,206 @@ +// 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`. + +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; + +/** 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 }; +} + +// -- 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[] = []; + // 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 || replyIds.has(validated.id)) continue; + replyIds.add(validated.id); + 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..707c50ed87 --- /dev/null +++ b/packages/collab-core/src/index.ts @@ -0,0 +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 new file mode 100644 index 0000000000..ae583d353c --- /dev/null +++ b/packages/collab-core/src/protocol.ts @@ -0,0 +1,185 @@ +// Wire protocol for the live-collaboration relay. +// +// 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 +// `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..dbac3fef93 --- /dev/null +++ b/packages/collab-core/src/session.ts @@ -0,0 +1,191 @@ +import type { + CollabChatMessage, + CollabCursor, + CollabParticipant, + CollabView, + 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; +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; + +/** + * 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 { + // 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 { + 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; +} + +/** + * 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/packages/collab-core/tsconfig.json b/packages/collab-core/tsconfig.json new file mode 100644 index 0000000000..c64d8b6d91 --- /dev/null +++ b/packages/collab-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "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..63c410d0e7 --- /dev/null +++ b/tests/collab-core-conformance.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + authorizeHostAction, + authorizeSnapshot, + clearParticipantOverrides, + MAX_SNAPSHOT_BYTES, + normalizeMode, + parseStoredChat, + participantCanEdit, + sanitizeColor, + sanitizeCursor, + sanitizeDisplayName, + sanitizeView, + 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); + // 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", () => { + 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"); + + // 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("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. + 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", () => { + 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"); + // 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, + 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..8278a63388 --- /dev/null +++ b/workers/collab-node/Dockerfile @@ -0,0 +1,28 @@ +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 +# --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 node:node /data +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 new file mode 100644 index 0000000000..3032576e78 --- /dev/null +++ b/workers/collab-node/README.md @@ -0,0 +1,38 @@ +# 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://`. + +## 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 new file mode 100644 index 0000000000..ab4df2bc7b --- /dev/null +++ b/workers/collab-node/package.json @@ -0,0 +1,23 @@ +{ + "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": "*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.18.1", + "esbuild": "^0.27.0" + }, + "engines": { + "node": ">=22.13" + } +} diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts new file mode 100644 index 0000000000..594c933bf5 --- /dev/null +++ b/workers/collab-node/src/server.ts @@ -0,0 +1,667 @@ +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; +/** 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(); + +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 }); + + // `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) { + 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 { + // ws delivers text as Buffer too; isBinary is handled at the event site. + const text = raw.toString(); + let message: ClientMessage; + try { + // `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"); + 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; + } + + // 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"); + 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") { + // 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, { + 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; + } + 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 && + 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) } + : {}; + // 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 ( + !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 : []).filter( + (entry): entry is Record => !!entry && typeof entry === "object", + ); + 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); + } + // 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); + } + } + + 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") { + // 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; + 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 (aborted) return; + // 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 <= MAX_SESSION_BODY_BYTES) { + 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; + // 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", () => { + if (aborted) return; + 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) => { + // 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])) { + 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); + alive.add(websocket); + websocket.on("pong", () => alive.add(websocket)); + websocket.on("message", (raw, isBinary) => { + if (isBinary) { + send(peer, { + type: "error", + code: "bad-message", + message: "Binary frames are not supported.", + }); + return; + } + // 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)); + }); + }); + + return { + 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"); + 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(); + }, + }; +} + +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}`); + }); + // 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 new file mode 100644 index 0000000000..0b50d728c3 --- /dev/null +++ b/workers/collab-node/src/store.ts @@ -0,0 +1,142 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { normalizeMode, parseStoredChat } from "@geolibre/collab-core"; +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, + // 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 + // 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, + }; + } + + saveSnapshot(id: string, project: unknown): number { + // 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 = ? + RETURNING rev`, + ) + .get(JSON.stringify(project), Date.now(), id) as { rev: number } | undefined; + return row?.rev ?? 0; + } + + 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); + } + + /** + * 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 new file mode 100644 index 0000000000..6e40d94607 --- /dev/null +++ b/workers/collab-node/test/relay.test.ts @@ -0,0 +1,313 @@ +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"; +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("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"); + 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("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("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"); + 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); + // 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 next(observer, "snapshot"); + observer.close(); + 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..6cf12a2b2a 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": "*" + }, "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..ab0ee8f5cb 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,46 +17,27 @@ import { validateComment, validateReply, } from "./comment-validate"; - -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})$/; - -/** 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; -} +import { + authorizeHostAction, + authorizeSnapshot, + CHAT_HISTORY_LIMIT, + clearParticipantOverrides, + EMPTY_SESSION_TTL_MS, + MAX_CHAT_STORAGE_BYTES, + MAX_CHAT_TEXT_LENGTH, + MAX_SNAPSHOT_BYTES, + parseStoredChat, + MIN_CHAT_INTERVAL_MS, + normalizeMode, + participantCanEdit, + sanitizeColor, + sanitizeCursor, + sanitizeDisplayName, + sanitizeView, + setParticipantOverride, + toWireParticipant, + type SessionParticipant, +} from "@geolibre/collab-core"; /** Parse the stored snapshot defensively: a corrupt value yields null rather * than throwing (which would lock joiners out of the session). */ @@ -75,97 +54,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. -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; +// 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 { - 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; -} - -/** 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 { - 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 []; - } -} +/** + * 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; type PresenceState = PresenceEntry; @@ -358,17 +265,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 +307,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)) { - 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) { + const decision = authorizeSnapshot(attachment, mode, byteLength); + if (!decision.ok) { 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 +370,29 @@ 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.attachedSockets(); + 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 +407,34 @@ 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); - // 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. + // `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, + socketsWithAttachments.map((entry) => entry.attachment), + message.clientId, + 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, + ); 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. @@ -608,12 +503,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[] { @@ -622,15 +523,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 +650,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",