diff --git a/backend/geolibre_server_api/geolibre_server_api/main.py b/backend/geolibre_server_api/geolibre_server_api/main.py index 71f4e98869..8a41863c7e 100644 --- a/backend/geolibre_server_api/geolibre_server_api/main.py +++ b/backend/geolibre_server_api/geolibre_server_api/main.py @@ -7,9 +7,11 @@ import re import secrets import shutil +import threading +import time import uuid from contextlib import asynccontextmanager, suppress -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Annotated, Callable, Literal from urllib.parse import quote, urlparse @@ -47,6 +49,8 @@ make_oauth_config, now, optional_principal, + password_hash, + password_matches, require_scope, token_digest, ) @@ -78,10 +82,17 @@ Version, ) from geolibre_server_api.projects import demote_disallowed_public_projects, log_project_activity -from geolibre_server_api.proxy_identity import load_trusted_proxy_config +from geolibre_server_api.proxy_identity import client_ip, load_trusted_proxy_config Visibility = Literal["public", "unlisted", "private", "organization"] PublicSharingPolicy = Literal["yes", "publishers", "no"] +SHARE_ACCESS_MAX_FAILURES = 10 +SHARE_ACCESS_WINDOW_SECONDS = 300 +SHARE_EXPIRY_DELTAS = { + "24h": timedelta(hours=24), + "7d": timedelta(days=7), + "30d": timedelta(days=30), +} JoinPolicy = Literal["invite", "request", "open"] SLUG_RE = re.compile(r"[^a-z0-9]+") # One entity-tag (RFC 9110 ยง8.8.3): optional weak prefix, then a quoted opaque @@ -110,6 +121,9 @@ class ProjectCreate(BaseModel): visibility: Visibility | None = None organization_id: str | None = Field(default=None, alias="organizationId") group_ids: list[str] = Field(default_factory=list, alias="groupIds", max_length=20) + role: Literal["view", "comment", "edit"] = "edit" + expires_in: Literal["24h", "7d", "30d", "never"] | None = Field(default=None, alias="expiresIn") + password: str | None = Field(default=None, min_length=1, max_length=200) class ProjectPatch(BaseModel): @@ -196,6 +210,10 @@ class ForkRequest(BaseModel): visibility: Visibility = "private" +class ShareAccessRequest(BaseModel): + password: str = Field(min_length=1, max_length=200) + + class ProjectTransferCreate(BaseModel): """Start a transfer: exactly one of ``username`` / ``organizationId``.""" @@ -335,6 +353,10 @@ def postgresql_upgrade_statements() -> list[str]: "ALTER TABLE projects ADD COLUMN IF NOT EXISTS created_by_id VARCHAR(36)", "ALTER TABLE projects ADD COLUMN IF NOT EXISTS delete_protected " "BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE projects ADD COLUMN IF NOT EXISTS share_role VARCHAR(8) " + "NOT NULL DEFAULT 'edit'", + "ALTER TABLE projects ADD COLUMN IF NOT EXISTS share_expires_at VARCHAR(32)", + "ALTER TABLE projects ADD COLUMN IF NOT EXISTS share_password_hash VARCHAR(200)", "ALTER TABLE projects ALTER COLUMN visibility TYPE VARCHAR(16)", "ALTER TABLE projects ALTER COLUMN owner_id DROP NOT NULL", "UPDATE projects SET created_by_id = owner_id WHERE created_by_id IS NULL", @@ -451,6 +473,9 @@ def upgrade_sqlite_schema(engine) -> None: ("organization_id", "VARCHAR(36)"), ("created_by_id", "VARCHAR(36)"), ("delete_protected", "BOOLEAN NOT NULL DEFAULT 0"), + ("share_role", "VARCHAR(8) NOT NULL DEFAULT 'edit'"), + ("share_expires_at", "VARCHAR(32)"), + ("share_password_hash", "VARCHAR(200)"), ], } with engine.begin() as connection: @@ -561,6 +586,9 @@ def upgrade_sqlite_schema(engine) -> None: fork_count INTEGER NOT NULL DEFAULT 0, featured BOOLEAN NOT NULL DEFAULT 0, delete_protected BOOLEAN NOT NULL DEFAULT 0, + share_role VARCHAR(8) NOT NULL DEFAULT 'edit', + share_expires_at VARCHAR(32), + share_password_hash VARCHAR(200), created_at VARCHAR(32) NOT NULL, updated_at VARCHAR(32) NOT NULL, CONSTRAINT uq_project_owner_slug UNIQUE (owner_id, slug), @@ -571,11 +599,13 @@ def upgrade_sqlite_schema(engine) -> None: INSERT INTO projects ( id, owner_id, created_by_id, organization_id, slug, title, description, visibility, tags_json, thumbnail_type, views, - fork_count, featured, delete_protected, created_at, updated_at + fork_count, featured, delete_protected, share_role, share_expires_at, + share_password_hash, created_at, updated_at ) SELECT id, owner_id, COALESCE(created_by_id, owner_id), organization_id, slug, title, description, visibility, tags_json, thumbnail_type, - views, fork_count, featured, delete_protected, created_at, updated_at + views, fork_count, featured, delete_protected, share_role, share_expires_at, + share_password_hash, created_at, updated_at FROM projects_legacy """) cursor.execute("DROP TABLE projects_legacy") @@ -1289,6 +1319,9 @@ def project_json( "versionCount": len(project.versions), "featured": project.featured, "deleteProtected": project.delete_protected, + "role": project.share_role, + "expiresAt": project.share_expires_at, + "hasPassword": project.share_password_hash is not None, "createdAt": project.created_at, "updatedAt": project.updated_at, "tags": json.loads(project.tags_json), @@ -1336,14 +1369,43 @@ def visible( return project raise HTTPException(404, "project not found") + def link_gate( + session: Session, + project: Project, + principal: AuthPrincipal | None, + password: str | None, + ) -> None: + """Enforce a share link's expiry and password against a non-manager reader. + + Managers always read their own projects. Everyone else gets 410 once the + link has expired, and 401 until the project's password is supplied. + """ + if project.share_expires_at is None and project.share_password_hash is None: + return + if principal is not None and can_manage_project(session, project, principal.account): + return + if project.share_expires_at is not None and ( + datetime.fromisoformat(project.share_expires_at.replace("Z", "+00:00")) + <= datetime.now(UTC) + ): + raise HTTPException(410, "share link expired") + if project.share_password_hash is not None and not ( + password is not None and password_matches(password, project.share_password_hash) + ): + raise HTTPException(401, "share password required") + def visible_read( - session: Session, project: Project | None, principal: AuthPrincipal | None + session: Session, + project: Project | None, + principal: AuthPrincipal | None, + password: str | None = None, ) -> Project: """visible() plus the read:projects scope for any non-public read. A public or unlisted project is readable anonymously, so no scope is needed. Anything else (private, organization, or reached only through a group share) is the caller's protected data and needs read:projects. + A share link's expiry and password are enforced here for every reader. """ project = visible(session, project, principal) if project.visibility not in {"public", "unlisted"}: @@ -1351,6 +1413,7 @@ def visible_read( # reaching here implies an authenticated principal. assert principal is not None ensure_scope(principal, "read:projects") + link_gate(session, project, principal, password) return project def can_manage_project(session: Session, project: Project, account: Account) -> bool: @@ -1391,7 +1454,9 @@ def editable(project: Project | None, principal: AuthPrincipal, session: Session def protected(project: Project) -> bool: """Whether a project's responses must not be publicly cached.""" - return project.visibility in {"private", "organization"} + return project.visibility in {"private", "organization"} or ( + project.share_password_hash is not None + ) def transfer_json(transfer: ProjectTransfer) -> dict: """Serialize a transfer row with the API's camelCase field names.""" @@ -1514,6 +1579,9 @@ def create_project( visibility: Visibility, organization_id: str | None = None, group_ids: list[str] | None = None, + share_role: str = "edit", + share_expires_at: str | None = None, + share_password: str | None = None, *, commit: bool = True, ) -> Project: @@ -1544,6 +1612,9 @@ def create_project( description="", visibility=visibility, tags_json="[]", + share_role=share_role, + share_expires_at=share_expires_at, + share_password_hash=password_hash(share_password) if share_password else None, created_at=timestamp, updated_at=timestamp, ) @@ -2553,6 +2624,19 @@ def post_project( ) if visibility == "public": ensure_scope(principal, "share:public") + if visibility not in {"public", "unlisted"} and ( + body.password or body.role != "edit" or body.expires_in not in (None, "never") + ): + # Link settings gate readers who reach the project through the link. + # On an organization or private project they would lock out members + # who have no way to learn the password. + raise HTTPException( + 422, "role, expiry, and password apply only to public or unlisted shares" + ) + delta = SHARE_EXPIRY_DELTAS.get(body.expires_in or "never") + expires_at = ( + (datetime.now(UTC) + delta).isoformat().replace("+00:00", "Z") if delta else None + ) return { "project": project_json( create_project( @@ -2563,12 +2647,72 @@ def post_project( visibility, body.organization_id, body.group_ids, + body.role, + expires_at, + body.password, ), session, principal.account, ) } + @app.get("/api/shares") + def list_shares( + principal: AuthPrincipal = Depends(require_scope("read:projects")), + session: Session = Depends(get_session), + ): + """The caller's active shares: managed public or unlisted projects. + + A share's id is its project id. Expired links stay listed so the owner can + see and revoke them. + """ + account = principal.account + candidates = session.scalars( + select(Project) + .options(*LISTING_EAGER_LOADS) + .where( + (Project.owner_id == account.id) + | (Project.created_by_id == account.id) + | Project.organization_id.in_( + select(OrganizationMember.organization_id).where( + OrganizationMember.account_id == account.id, + OrganizationMember.role == "administrator", + ) + ), + Project.visibility.in_(("public", "unlisted")), + ) + .order_by(Project.updated_at.desc()) + ).all() + return { + "shares": [ + project_json(project, session, account) | {"projectSlug": project.slug} + for project in candidates + if can_manage_project(session, project, account) + ] + } + + @app.delete("/api/shares/{share_id}", status_code=204) + def revoke_share( + share_id: str, + principal: AuthPrincipal = Depends(require_scope("write:projects")), + session: Session = Depends(get_session), + ): + """Revoke a share: make the project private and clear its link settings. + + The project and its versions are kept; only the link stops working. + """ + project = owned(session, session.get(Project, share_id), principal) + if project.visibility not in {"public", "unlisted"}: + # Organization-wide access is not a link share; revoking it here would + # silently remove it, so it stays managed through project settings. + raise HTTPException(404, "share not found") + project.visibility = "private" + project.share_role = "edit" + project.share_expires_at = None + project.share_password_hash = None + project.updated_at = now() + session.commit() + @app.get("/api/projects") def list_projects( response: Response, @@ -3234,6 +3378,7 @@ def list_versions( ): """List a visible project's versions, newest first (needs read:projects).""" project = visible(session, session.get(Project, project_id), principal) + link_gate(session, project, principal, None) body = { "versions": [ { @@ -3445,6 +3590,109 @@ def latest_raw( session.commit() return body + access_attempts: dict[tuple[str, str], list[float]] = {} + access_attempts_lock = threading.Lock() + + def reserve_access_attempt(key: tuple[str, str]) -> float: + """Count one attempt against ``key`` or raise 429, atomically. + + The attempt is reserved before the password is checked so concurrent + requests cannot all pass the limit check ahead of any recorded failure. + """ + stamp = time.monotonic() + cutoff = stamp - SHARE_ACCESS_WINDOW_SECONDS + with access_attempts_lock: + if len(access_attempts) > 1024: + for stale in [ + other + for other, stamps in access_attempts.items() + if not stamps or stamps[-1] <= cutoff + ]: + del access_attempts[stale] + recent = [seen for seen in access_attempts.get(key, []) if seen > cutoff] + if len(recent) >= SHARE_ACCESS_MAX_FAILURES: + access_attempts[key] = recent + raise HTTPException(429, "too many incorrect passwords; try again later") + access_attempts[key] = [*recent, stamp] + return stamp + + def release_access_attempt(key: tuple[str, str], stamp: float) -> None: + """Give back a reserved attempt that was not a wrong password.""" + with access_attempts_lock: + stamps = access_attempts.get(key, []) + if stamp in stamps: + stamps.remove(stamp) + + def share_access_response( + request: Request, + session: Session, + project: Project | None, + principal: AuthPrincipal | None, + password: str, + ) -> Response: + """Return the latest content and share role once the link password checks out. + + Wrong guesses are throttled per project and client address, because each + check costs a scrypt hash and the route is anonymous. + """ + project = visible(session, project, principal) + key = (project.id, str(client_ip(request) or "")) + stamp = reserve_access_attempt(key) + try: + project = visible_read(session, project, principal, password) + except HTTPException as error: + if error.status_code != 401: + release_access_attempt(key, stamp) + raise + release_access_attempt(key, stamp) + try: + content = object_storage.get(project.versions[-1].object_key) + except KeyError: + raise HTTPException(404, "project content not found") + return Response( + json.dumps({"content": content.decode(), "role": project.share_role}), + media_type="application/json", + headers={"Cache-Control": "private, no-store"}, + ) + + @app.post("/org/{organization_slug}/{slug}/access") + def organization_share_access( + organization_slug: str, + slug: str, + body: ShareAccessRequest, + request: Request, + principal: AuthPrincipal | None = Depends(optional_principal), + session: Session = Depends(get_session), + ): + """Unlock a password-protected organization project link.""" + project = session.scalar( + select(Project) + .join(Organization) + .where(Organization.slug == organization_slug, Project.slug == slug) + ) + return share_access_response(request, session, project, principal, body.password) + + @app.post("/{username}/{slug}/access") + def share_access( + username: str, + slug: str, + body: ShareAccessRequest, + request: Request, + principal: AuthPrincipal | None = Depends(optional_principal), + session: Session = Depends(get_session), + ): + """Unlock a password-protected personal project link.""" + project = session.scalar( + select(Project) + .join(Account, Project.owner_id == Account.id) + .where( + Account.username == username, + Project.slug == slug, + Project.organization_id.is_(None), + ) + ) + return share_access_response(request, session, project, principal, body.password) + @app.get("/{username}/{slug}") def project_page( username: str, diff --git a/backend/geolibre_server_api/geolibre_server_api/project_models.py b/backend/geolibre_server_api/geolibre_server_api/project_models.py index 320a58da60..bfee8ae29d 100644 --- a/backend/geolibre_server_api/geolibre_server_api/project_models.py +++ b/backend/geolibre_server_api/geolibre_server_api/project_models.py @@ -37,6 +37,11 @@ class Project(Base): # Owner opt-in: while true the project refuses DELETE with a 409 naming this # switch. Off by default, per GeoLibre#1670. delete_protected: Mapped[bool] = mapped_column(Boolean, default=False) + # Share-link settings. Role is advisory metadata echoed to viewers; expiry and + # password are enforced by the server on every anonymous read. + share_role: Mapped[str] = mapped_column(String(8), default="edit", server_default="edit") + share_expires_at: Mapped[str | None] = mapped_column(String(32), nullable=True) + share_password_hash: Mapped[str | None] = mapped_column(String(200), nullable=True) created_at: Mapped[str] = mapped_column(String(32)) updated_at: Mapped[str] = mapped_column(String(32), index=True) owner: Mapped[Account | None] = relationship(back_populates="projects", foreign_keys=[owner_id]) diff --git a/backend/geolibre_server_api/tests/test_shares.py b/backend/geolibre_server_api/tests/test_shares.py new file mode 100644 index 0000000000..404d1cd9dd --- /dev/null +++ b/backend/geolibre_server_api/tests/test_shares.py @@ -0,0 +1,238 @@ +"""Active shares: listing, revocation, and enforcement of link expiry and password.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +from geolibre_server_api.project_models import Project +from helpers import account, auth +from sqlalchemy.orm import Session + + +def share(client, token, title="Wetlands", **extra): + content = json.dumps({"version": "1.0", "title": title, "layers": []}) + body = {"filename": "f.geolibre.json", "content": content, "visibility": "unlisted", **extra} + response = client.post("/api/projects", headers=auth(token), json=body) + assert response.status_code == 201, response.text + return response.json()["project"], content + + +def test_list_shares_returns_only_callers_non_private_projects(client): + ada = account(client, "ada") + bob = account(client, "bob") + shared, _ = share(client, ada, "Shared", role="view", expiresIn="7d", password="pw") + share(client, ada, "Hidden", visibility="private") + share(client, bob, "Bobs") + + response = client.get("/api/shares", headers=auth(ada)) + + assert response.status_code == 200 + (item,) = response.json()["shares"] + assert item["id"] == shared["id"] + assert item["projectSlug"] == shared["slug"] + assert item["role"] == "view" + assert item["hasPassword"] is True + assert item["expiresAt"] is not None + + +def test_shares_require_authentication(client): + assert client.get("/api/shares").status_code == 401 + assert client.delete("/api/shares/anything").status_code == 401 + + +def test_revoke_makes_project_private_and_keeps_content(client): + ada = account(client, "ada") + bob = account(client, "bob") + project, _ = share(client, ada, password="pw", role="view") + + assert client.delete(f"/api/shares/{project['id']}", headers=auth(bob)).status_code == 403 + assert client.delete(f"/api/shares/{project['id']}", headers=auth(ada)).status_code == 204 + + assert client.get(f"/ada/{project['slug']}.geolibre.json").status_code == 404 + assert client.get("/api/shares", headers=auth(ada)).json()["shares"] == [] + kept = client.get(f"/api/projects/{project['id']}", headers=auth(ada)).json()["project"] + assert kept["visibility"] == "private" + assert kept["hasPassword"] is False + assert kept["role"] == "edit" + # Revoking twice is a 404: there is no active share left. + assert client.delete(f"/api/shares/{project['id']}", headers=auth(ada)).status_code == 404 + + +def test_password_protects_raw_json_until_access_is_unlocked(client): + ada = account(client, "ada") + project, content = share(client, ada, password="s3cret", role="comment") + raw = f"/ada/{project['slug']}.geolibre.json" + access = f"/ada/{project['slug']}/access" + + assert client.get(raw).status_code == 401 + assert client.post(access, json={"password": "nope"}).status_code == 401 + + unlocked = client.post(access, json={"password": "s3cret"}) + assert unlocked.status_code == 200 + assert unlocked.json() == {"content": content, "role": "comment"} + assert unlocked.headers["cache-control"] == "private, no-store" + # The owner reads their own project without the password. + assert client.get(raw, headers=auth(ada)).status_code == 200 + + +def test_expired_link_is_gone_for_readers_but_not_owner(client): + ada = account(client, "ada") + project, _ = share(client, ada, expiresIn="24h") + raw = f"/ada/{project['slug']}.geolibre.json" + assert client.get(raw).status_code == 200 + + expired = (datetime.now(UTC) - timedelta(minutes=1)).isoformat().replace("+00:00", "Z") + with Session(client.app.state.engine) as session: + session.get(Project, project["id"]).share_expires_at = expired + session.commit() + + assert client.get(raw).status_code == 410 + assert client.get(raw, headers=auth(ada)).status_code == 200 + # Still listed so the owner can revoke it. + listed = client.get("/api/shares", headers=auth(ada)).json()["shares"] + assert [s["id"] for s in listed] == [project["id"]] + + +def test_link_without_settings_is_unaffected(client): + ada = account(client, "ada") + project, content = share(client, ada) + assert project["role"] == "edit" + assert project["expiresAt"] is None + assert project["hasPassword"] is False + assert client.get(f"/ada/{project['slug']}.geolibre.json").text == content + + +def test_organization_visible_projects_are_not_link_shares(client): + ada = account(client, "ada") + organization = client.post( + "/api/organizations", headers=auth(ada), json={"slug": "lab", "name": "Lab"} + ).json()["organization"] + project, _ = share(client, ada, visibility="organization", organizationId=organization["id"]) + + assert client.get("/api/shares", headers=auth(ada)).json()["shares"] == [] + assert client.delete(f"/api/shares/{project['id']}", headers=auth(ada)).status_code == 404 + kept = client.get(f"/api/projects/{project['id']}", headers=auth(ada)).json()["project"] + assert kept["visibility"] == "organization" + + +def test_password_gate_covers_project_api_and_organization_access(client): + ada = account(client, "ada") + organization = client.post( + "/api/organizations", headers=auth(ada), json={"slug": "lab", "name": "Lab"} + ).json()["organization"] + project, content = share( + client, ada, visibility="public", organizationId=organization["id"], password="pw" + ) + + assert client.get(f"/api/projects/{project['id']}").status_code == 401 + assert client.get(f"/org/lab/{project['slug']}.geolibre.json").status_code == 401 + unlocked = client.post(f"/org/lab/{project['slug']}/access", json={"password": "pw"}) + assert unlocked.json() == {"content": content, "role": "edit"} + assert client.get(f"/api/projects/{project['id']}", headers=auth(ada)).status_code == 200 + + +def test_access_enforces_expiry_before_password(client): + ada = account(client, "ada") + project, _ = share(client, ada, expiresIn="24h", password="pw") + expired = (datetime.now(UTC) - timedelta(minutes=1)).isoformat().replace("+00:00", "Z") + with Session(client.app.state.engine) as session: + session.get(Project, project["id"]).share_expires_at = expired + session.commit() + + response = client.post(f"/ada/{project['slug']}/access", json={"password": "pw"}) + assert response.status_code == 410 + + +def test_wrong_passwords_are_throttled(client): + ada = account(client, "ada") + project, _ = share(client, ada, password="pw") + access = f"/ada/{project['slug']}/access" + + statuses = [client.post(access, json={"password": "bad"}).status_code for _ in range(11)] + + assert statuses[:10] == [401] * 10 + assert statuses[10] == 429 + # Even the right password is refused while throttled. + assert client.post(access, json={"password": "pw"}).status_code == 429 + + +def test_version_list_is_gated_by_the_share_password(client): + ada = account(client, "ada") + bob = account(client, "bob") + project, _ = share(client, ada, password="pw") + + assert ( + client.get(f"/api/projects/{project['id']}/versions", headers=auth(bob)).status_code == 401 + ) + assert ( + client.get(f"/api/projects/{project['id']}/versions", headers=auth(ada)).status_code == 200 + ) + + +def test_organization_administrator_sees_and_revokes_shares_they_did_not_create(client): + ada = account(client, "ada") + organization = client.post( + "/api/organizations", headers=auth(ada), json={"slug": "lab", "name": "Lab"} + ).json()["organization"] + project, _ = share(client, ada, visibility="public", organizationId=organization["id"]) + bob = account(client, "bob") + invitation = client.put( + f"/api/organizations/{organization['id']}/members", + headers=auth(ada), + json={"username": "bob", "role": "administrator"}, + ) + assert invitation.status_code in (200, 201), invitation.text + + listed = client.get("/api/shares", headers=auth(bob)) + assert [s["id"] for s in listed.json()["shares"]] == [project["id"]] + assert client.delete(f"/api/shares/{project['id']}", headers=auth(bob)).status_code == 204 + + +def test_invalid_share_settings_are_rejected(client): + ada = account(client, "ada") + content = json.dumps({"version": "1.0", "title": "T", "layers": []}) + for extra in ({"role": "owner"}, {"expiresIn": "1y"}, {"password": ""}): + response = client.post( + "/api/projects", + headers=auth(ada), + json={"filename": "f.json", "content": content, "visibility": "unlisted", **extra}, + ) + assert response.status_code == 422, extra + + +def test_concurrent_wrong_passwords_cannot_exceed_the_limit(client): + from concurrent.futures import ThreadPoolExecutor + + ada = account(client, "ada") + project, _ = share(client, ada, password="pw") + access = f"/ada/{project['slug']}/access" + + with ThreadPoolExecutor(max_workers=12) as pool: + statuses = list( + pool.map(lambda _: client.post(access, json={"password": "bad"}).status_code, range(30)) + ) + + assert statuses.count(401) == 10 + assert statuses.count(429) == 20 + + +def test_link_settings_are_refused_on_non_link_visibility(client): + ada = account(client, "ada") + organization = client.post( + "/api/organizations", headers=auth(ada), json={"slug": "lab", "name": "Lab"} + ).json()["organization"] + content = json.dumps({"version": "1.0", "title": "T", "layers": []}) + for visibility, extra in ( + ("organization", {"organizationId": organization["id"], "password": "pw"}), + ("private", {"expiresIn": "7d"}), + ("private", {"role": "view"}), + ): + response = client.post( + "/api/projects", + headers=auth(ada), + json={"filename": "f.json", "content": content, "visibility": visibility, **extra}, + ) + assert response.status_code == 422, (visibility, extra) + # Defaults are fine on any visibility. + assert share(client, ada, visibility="private")[0]["hasPassword"] is False diff --git a/docs/server-api.md b/docs/server-api.md index f1f4823b04..617a458379 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -763,6 +763,25 @@ a non-administrator may list only that organization's groups. When `visibility` is omitted, the organization's `defaultVisibility` applies, or `private` for a personal project. +Optional share-link settings (only for `public` or `unlisted` projects; any other +visibility answers `422` when one is set): `role` (`view`, `comment`, or `edit`; default +`edit`), `expiresIn` (`24h`, `7d`, `30d`, or `never`), and `password`. They are +echoed in every project representation as `role`, `expiresAt` (ISO timestamp or +`null`), and `hasPassword`. `role` is metadata for viewers; the server enforces +only the expiry and the password. Once `expiresAt` has passed, anyone but a +manager of the project gets `410` (`share link expired`) from every read route. +While a password is set, those readers get `401` (`share password required`) +until they unlock the link with `POST /{username}/{slug}/access` (or +`POST /org/{organization}/{slug}/access` for an organization project) with +`{"password": "..."}`. That returns `{"content": "", "role": "view"}` +with `Cache-Control: private, no-store`. After 10 wrong passwords within 5 minutes +from one client address, the route answers `429` for that project, even for the +right password. The count is kept in memory, per server process. + +The version-list route (`GET /api/projects/{id}/versions`) takes no password, so +while a password is set it answers `401` to everyone except a manager of the +project. + ### `GET /api/projects` Returns a page in newest-updated-first order: @@ -860,6 +879,22 @@ on it to explain the refusal. Turning the switch off with Deleting a project also removes its pending transfers and its redirect rows. +### `GET /api/shares` + +Requires `read:projects`. Returns `{"shares": [, ...]}`, newest-updated +first: the projects the caller manages whose `visibility` is `public` or `unlisted` +(organization-visible projects are not link shares). Each +entry is a project representation plus `projectSlug`. A share's `id` is its +project id. Expired links stay listed so they can be revoked. + +### `DELETE /api/shares/{id}` + +Requires `write:projects` and management of the project. Revokes the share: the +project becomes `private` and its `role`, expiry, and password are reset. The +project, its versions, and its group shares are kept. Response: `204`; `403` when +the caller does not manage the project; `404` when the project is unknown, already +private, or organization-visible. + ### `GET /api/projects/{id}/activity` Requires ownership. Returns the project's activity log, newest first, capped