Files
pad/internal/server/handlers_ref_resolver.go
T

301 lines
12 KiB
Go

package server
import (
"net/http"
"regexp"
"strconv"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/go-chi/chi/v5"
)
// refResolverRefPattern matches the wiki-link ref shape: a letter-led
// alphanumeric prefix, a hyphen, and a positive integer. Mirrors the
// renderer's client-side regex so a 404 here is congruent with what the
// editor renders as a broken link. Anchored to reject ambiguous inputs
// before any DB lookup (the validator runs BEFORE workspace resolution,
// so a malformed REF can't reveal whether the workspace exists).
var refResolverRefPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*-\d+$`)
// handleResolveCrossWorkspaceRef implements IDEA-1492's resolver route.
//
// GET /-/r/{workspace}/{REF}
//
// The `/-/r/` prefix is structurally impossible to collide with any
// page route under /{username}/{workspace}/{collection}/... because
// username and collection slugs both require a leading letter. This
// shape sidesteps Codex round-2 P1.4 (pre-existing `ref`-slugged
// collections in upgraded workspaces) without a migration.
//
// 404 cases (in order of evaluation):
//
// 1. REF doesn't match the wiki-link pattern. Rejected before the DB hit
// so anonymous probes can't enumerate workspace existence by ref shape.
// 2. Workspace slug doesn't resolve, OR resolves but the current viewer
// lacks access. We deliberately return 404 (not 403) — the brief calls
// for "don't leak workspace existence", so members of a different
// workspace see the same response as anonymous viewers.
// 3. Ref resolves to no item in the target workspace.
// 4. The item's collection isn't visible to the viewer.
// 5. The workspace's owner has no username on record — the canonical
// redirect target requires one, and `"/" + "" + "/" + slug + …`
// would produce a protocol-relative URL (`//slug/…`) the browser
// interprets as a network-path reference. Returning 404 here is
// safer than emitting a broken redirect (Codex round-2 P1.3).
func (s *Server) handleResolveCrossWorkspaceRef(w http.ResponseWriter, r *http.Request) {
workspaceSlug := chi.URLParam(r, "workspace")
ref := chi.URLParam(r, "ref")
// 1. Validate REF shape FIRST — cheap, no DB hit, and doesn't leak
// whether the workspace exists. A malformed REF on a real workspace
// looks the same as a malformed REF on a phantom workspace.
if !refResolverRefPattern.MatchString(ref) {
s.refResolverNotFound(w, r)
return
}
// 2. Resolve the workspace. Uses currentUser(r) so the ACL is
// consistent: members + guests with grants see the workspace;
// everyone else gets nil (→ 404).
ws, err := s.resolveWorkspace(workspaceSlug, currentUser(r))
if err != nil {
// Internal error path — write a generic 404 rather than 500 to keep
// the no-leak contract intact for ambiguous failures.
s.refResolverNotFound(w, r)
return
}
if ws == nil {
s.refResolverNotFound(w, r)
return
}
// 3. Resolve the ref within the workspace. parseRefForRedirect
// canonicalizes the prefix to uppercase so it lines up with
// GetItemByRef's exact-prefix path; refs that fail the stricter
// A-Z prefix rule still resolve via the workspace-unique number
// fallback inside GetItemByRef.
prefix, number, ok := parseRefForRedirect(ref)
if !ok {
s.refResolverNotFound(w, r)
return
}
item, err := s.store.GetItemByRef(ws.ID, prefix, number)
if err != nil || item == nil {
s.refResolverNotFound(w, r)
return
}
// 4. ACL: replay RequireWorkspaceAccess's role-derivation logic for
// the viewer, then delegate to checkItemVisible — the same context-
// free helper the middleware-gated routes use. Drift between the
// resolver's ACL and the rest of the system is structurally
// impossible.
visible, err := s.resolverItemVisible(r, ws, item)
if err != nil || !visible {
s.refResolverNotFound(w, r)
return
}
// 5. Determine the username segment for the canonical redirect target.
// The new URL shape has no URL-path username, so we always synthesize
// from the workspace owner. Empty result → 404 rather than a broken
// `//slug/…` protocol-relative URL (Codex round-2 P1.3).
username := s.resolverOwnerUsername(ws)
if username == "" {
s.refResolverNotFound(w, r)
return
}
// 6. Build the canonical item URL and 302 to it. Matches itemUrlId()
// in the frontend so the redirect target is indistinguishable from a
// direct in-app navigation.
dest := "/" + username + "/" + ws.Slug + "/" + item.CollectionSlug + "/"
if item.ItemNumber != nil && *item.ItemNumber > 0 && item.CollectionPrefix != "" {
dest += item.CollectionPrefix + "-" + strconv.Itoa(*item.ItemNumber)
} else {
dest += item.Slug
}
http.Redirect(w, r, dest, http.StatusFound)
}
// refResolverNotFound writes a 404 with no body details. Centralized so all
// failure paths produce identical responses — preventing oracle-style probes
// that compare response bodies to distinguish "workspace missing" from
// "ref missing" from "no access" from "owner-username missing".
func (s *Server) refResolverNotFound(w http.ResponseWriter, _ *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Not found")
}
// resolverItemVisible derives the viewer's workspace role outside the
// RequireWorkspaceAccess middleware path, then delegates to
// checkItemVisible. The role-derivation mirrors RequireWorkspaceAccess's
// rules byte-for-byte:
//
// - Pre-setup mode (UserCount == 0) → implicit "owner" so the
// route is reachable before the first admin exists.
// - Admin user → "owner" (admin gets owner-equivalent access to every
// workspace).
// - Workspace owner → "owner".
// - Member with explicit role → that role ("owner" / "editor" / "viewer").
// - Non-member with workspace grants → "guest".
// - Otherwise → not visible (returns false, nil — distinct from an error).
//
// The returned (false, err) pair is reserved for genuine DB errors; the
// caller still maps both to a 404 to honor the no-leak contract.
//
// Note this route does NOT consult the OAuth/MCP token consent allow-list,
// and — because it is registered inside the full middleware stack — it IS
// reachable by PATs and CLI session bearers. What keeps that acceptable is
// that it discloses nothing but a 302 to a URL the caller can already read.
// Do not copy it as a template for a cross-workspace surface that returns
// data: use AuthorizeCrossWorkspaceRead (authz_cross_workspace.go), which
// checks the allow-list.
func (s *Server) resolverItemVisible(r *http.Request, ws *models.Workspace, item *models.Item) (bool, error) {
user := currentUser(r)
// Pre-setup mode: no users yet, the whole system is open (matches
// RequireAuth + RequireWorkspaceAccess's fresh-install bypass). We
// short-circuit to visible HERE rather than calling checkItemVisible
// with a synthetic role because the round-2-extended checkItemVisible
// honors role-only bypasses ("owner"/"editor") for tokenized access,
// but the pre-setup path is conceptually distinct from a real
// authenticated owner — bypassing here keeps the intent explicit.
if user == nil {
count, err := s.store.UserCount()
if err != nil {
return false, err
}
if count == 0 {
return true, nil
}
// Authenticated-instance anonymous viewer: no item-read access via
// the resolver. Share links own the public-read surface via
// /s/{token}.
return false, nil
}
// Derive the role the same way RequireWorkspaceAccess does. Pass the
// bearer signal so a bearer-borne platform admin doesn't get a
// cross-workspace owner bypass (BUG-1618).
authIsBearer := isBearerAuth(r)
role, err := s.resolverWorkspaceRole(ws, user, authIsBearer)
if err != nil {
return false, err
}
if role == "" {
// Not a member, no grants, not admin/owner. Not visible.
return false, nil
}
// checkItemVisible's own admin bypass must also be bearer-gated
// (BUG-1918) — otherwise a bearer-admin member with a restricted
// role (correctly derived above) would still get the unconditional
// admin bypass inside checkItemVisible, reopening the exact
// cross-workspace leak resolverWorkspaceRole's authIsBearer param
// exists to close.
return s.checkItemVisible(ws.ID, item, user, role, authIsBearer)
}
// resolverWorkspaceRole reproduces RequireWorkspaceAccess's role lookup
// for a (workspace, user) pair without the *http.Request scaffolding.
// Returns "" when the user has no role and no grants in the workspace.
// The "owner" return value covers the actual workspace owner AND
// cookie-session admins (admin gets owner-equivalent access) —
// checkItemVisible treats "owner" uniformly so the conflation is safe.
//
// authIsBearer narrows the admin bypass: a platform admin authenticated
// via a bearer surface (PAT / CLI / MCP) does NOT get cross-workspace
// owner access — they fall through to the member-then-grants check, the
// membership-only stance set by BUG-1616/1617. Cookie-session admins
// keep the owner bypass so the web-UI affordance is preserved. The
// workspace owner check is unconditional regardless of auth surface
// (BUG-1618).
//
// A non-nil error is a genuine store failure, never "no role". Callers MUST
// fail closed on it. Pre-TASK-2358 this helper swallowed both lookup errors
// and silently continued to the next branch, which is how a transient DB
// blip could downgrade a member to the guest-grants path. The resolver route
// maps error and "" alike to a 404, so its visible behavior is unchanged.
//
// NOT reusable for a second workspace, despite the shape. The ws.OwnerID
// short-circuit below fires on every auth surface (BUG-1618, a deliberate
// widening for this cookie-only redirect route), whereas
// RequireWorkspaceAccess requires an actual workspace_members row for every
// bearer caller. Cross-workspace callers use Server.crossWorkspaceRole
// (authz_cross_workspace.go), which tracks the middleware instead so it can
// never grant more than the front door.
func (s *Server) resolverWorkspaceRole(ws *models.Workspace, user *models.User, authIsBearer bool) (string, error) {
if ws.OwnerID == user.ID || (user.Role == "admin" && !authIsBearer) {
return "owner", nil
}
member, err := s.store.GetWorkspaceMember(ws.ID, user.ID)
if err != nil {
return "", err
}
if member != nil {
return member.Role, nil
}
// Bearer-borne platform admin who isn't a member gets NO grant-based
// fallback — the membership-only stance (BUG-1616/1617/1618). Without
// this guard a single stray collection/item grant would yield "guest"
// below, and checkItemVisible's own `user.Role == "admin"` bypass
// (server.go) would then reopen full resolver access + 302 URL leakage
// for every item in the workspace. RequireWorkspaceAccess denies
// bearer-admin non-members before checking grants for the same reason.
if user.Role == "admin" && authIsBearer {
return "", nil
}
// Not a member — guest path requires at least one grant.
hasGrants, err := s.store.UserHasGrantsInWorkspace(ws.ID, user.ID)
if err != nil {
return "", err
}
if hasGrants {
return "guest", nil
}
return "", nil
}
// resolverOwnerUsername returns the workspace owner's username — the
// only source for the leading path segment of the canonical redirect
// target (since `/-/r/{workspace}/{ref}` URLs carry no username
// themselves). Returns "" when the owner record is missing or has no
// username on file. Callers MUST treat empty as "can't build a valid
// redirect" and 404 — emitting `/` + "" + `/slug/...` yields a
// protocol-relative URL the browser interprets as a network-path
// reference (Codex round-2 P1.3).
func (s *Server) resolverOwnerUsername(ws *models.Workspace) string {
if ws.OwnerUsername != "" {
return ws.OwnerUsername
}
if ws.OwnerID == "" {
return ""
}
user, err := s.store.GetUser(ws.OwnerID)
if err != nil || user == nil {
return ""
}
return user.Username
}
// parseRefForRedirect splits a validated ref (the regex caller already
// confirmed `[A-Za-z][A-Za-z0-9]*-\d+`) into its uppercase prefix and
// number. GetItemByRef's primary path matches on exact prefix; its
// fallback path (workspace-unique number alone) handles items that have
// been moved to a different collection, so a digit-bearing prefix that
// doesn't match store.parseItemRef's stricter A-Z rule still resolves via
// the number lookup.
func parseRefForRedirect(s string) (string, int, bool) {
up := strings.ToUpper(s)
dash := strings.LastIndex(up, "-")
if dash <= 0 || dash == len(up)-1 {
return "", 0, false
}
prefix := up[:dash]
num, err := strconv.Atoi(up[dash+1:])
if err != nil || num <= 0 {
return "", 0, false
}
return prefix, num, true
}