diff --git a/internal/import_library.go b/internal/import_library.go index 793cf4ca8..aa3104c7d 100644 --- a/internal/import_library.go +++ b/internal/import_library.go @@ -36,13 +36,76 @@ type libraryBlockLocation struct { blockFolder string } +// libraryImportAuth is the outcome of the import-library auth gate. Exactly one +// of {allow, reject} is meaningful per call: reject != "" means deny with that +// reason; otherwise the request proceeds. freshServer marks an unauthenticated +// zero-site seed, which is forced strictly additive downstream. +type libraryImportAuth struct { + reject string // "" = allowed; otherwise "unauthorized" or "internal" + freshServer bool +} + +// libraryImportAuthDecision decides whether an import-library request may +// proceed. Authenticated or localhost callers always pass. An unauthenticated +// remote caller is allowed ONLY on a fresh server (zero sites) whose library is +// also still empty, mirroring the bootstrap endpoint's guard so `primo deploy` +// can seed the library in the same pre-account window it seeds sites. +// +// The library-empty requirement matters because import is upsert-by-name, not +// create-only: processLibraryImport matches existing groups/symbols by name and +// updates them (and re-imports clear a matched block's stale fields/entries). +// The library is instance-wide, so a zero-site server can still hold library +// records; without this check an unauthenticated caller could overwrite them +// during the deploy window. Requiring an empty library makes the path genuinely +// create-only. We fail closed: if either lookup errored we can't prove the +// server is fresh, so we reject rather than allow. +// +// Extracted as a pure function so the security-sensitive gate is unit-testable +// without an HTTP/PocketBase harness (the handler has no such test scaffold). +func libraryImportAuthDecision(authed, isLocal bool, siteCount, libraryCount int, lookupErr error) libraryImportAuth { + if authed || isLocal { + return libraryImportAuth{} + } + if lookupErr != nil { + return libraryImportAuth{reject: "internal"} + } + if siteCount > 0 || libraryCount > 0 { + return libraryImportAuth{reject: "unauthorized"} + } + return libraryImportAuth{freshServer: true} +} + func RegisterLibraryImportEndpoint(pb *pocketbase.PocketBase) error { pb.OnServe().BindFunc(func(serveEvent *core.ServeEvent) error { serveEvent.Router.POST("/api/primo/import-library", func(e *core.RequestEvent) error { + // Only count sites/library when it can matter (unauthenticated + // remote caller); authed/localhost skip the queries entirely. The + // unauthenticated path requires BOTH zero sites and an empty + // library — see libraryImportAuthDecision. + authed := e.Auth != nil isLocal := IsLocalhost(e) - if e.Auth == nil && !isLocal { + var siteCount, libraryCount int64 + var lookupErr error + if !authed && !isLocal { + siteCount, lookupErr = pb.CountRecords("sites") + if lookupErr == nil { + var symbolCount, groupCount int64 + symbolCount, lookupErr = pb.CountRecords("library_symbols") + if lookupErr == nil { + groupCount, lookupErr = pb.CountRecords("library_symbol_groups") + } + libraryCount = symbolCount + groupCount + } + } + + decision := libraryImportAuthDecision(authed, isLocal, int(siteCount), int(libraryCount), lookupErr) + switch decision.reject { + case "internal": + return e.InternalServerError("Failed to check whether the server is fresh", lookupErr) + case "unauthorized": return e.UnauthorizedError("Authentication required", nil) } + freshServer := decision.freshServer if err := e.Request.ParseMultipartForm(32 << 20); err != nil { return e.BadRequestError("Failed to parse form", err) @@ -71,6 +134,15 @@ func RegisterLibraryImportEndpoint(pb *pocketbase.PocketBase) error { } } + // The unauthenticated fresh-server path is gated on an empty library + // (see libraryImportAuthDecision), so it only ever creates records — + // there is nothing to update or delete. Belt-and-suspenders: drop any + // deletes manifest so a caller can't smuggle deletions in even if the + // emptiness check and this import were to race. + if freshServer { + deletes = DeletesManifest{} + } + summary, err := processLibraryImport(pb, zipData, deletes) if err != nil { return e.InternalServerError("Library import failed: "+err.Error(), err) diff --git a/internal/import_library_test.go b/internal/import_library_test.go new file mode 100644 index 000000000..e785402bf --- /dev/null +++ b/internal/import_library_test.go @@ -0,0 +1,91 @@ +package internal + +import ( + "errors" + "testing" +) + +// The import-library auth gate is security-sensitive: it opens an +// unauthenticated write path, so its exact conditions are pinned here. The +// gate must (a) always let authed/localhost callers through, (b) allow an +// unauthenticated remote caller ONLY on a fresh server — zero sites AND an +// empty library, so the path is genuinely create-only — and (c) fail closed +// when a lookup errors. +func TestLibraryImportAuthDecision(t *testing.T) { + lookupErr := errors.New("db down") + + cases := []struct { + name string + authed bool + isLocal bool + siteCount int + libraryCount int + lookupErr error + wantReject string + wantFresh bool + }{ + { + name: "authed remote passes without lookup", + authed: true, + }, + { + name: "localhost passes without lookup", + isLocal: true, + }, + { + name: "authed still passes even with sites and library present", + authed: true, + siteCount: 5, + libraryCount: 12, + }, + { + name: "unauthenticated remote on fully fresh server is allowed and create-only", + // zero sites, empty library + wantFresh: true, + }, + { + name: "unauthenticated remote with sites is rejected", + siteCount: 1, + wantReject: "unauthorized", + }, + { + name: "unauthenticated remote with an existing library is rejected even at zero sites", + libraryCount: 1, + wantReject: "unauthorized", + }, + { + name: "site lookup error fails closed", + lookupErr: lookupErr, + wantReject: "internal", + }, + { + name: "lookup error takes precedence over zero counts", + siteCount: 0, + libraryCount: 0, + lookupErr: lookupErr, + wantReject: "internal", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := libraryImportAuthDecision(tc.authed, tc.isLocal, tc.siteCount, tc.libraryCount, tc.lookupErr) + if got.reject != tc.wantReject { + t.Errorf("reject = %q, want %q", got.reject, tc.wantReject) + } + if got.freshServer != tc.wantFresh { + t.Errorf("freshServer = %v, want %v", got.freshServer, tc.wantFresh) + } + // A rejected request must never be marked fresh (would imply a + // create-only seed on a denied path). + if got.reject != "" && got.freshServer { + t.Errorf("rejected decision marked freshServer") + } + // A fresh decision must be strictly create-only: zero sites AND + // empty library. + if got.freshServer && (tc.siteCount > 0 || tc.libraryCount > 0) { + t.Errorf("freshServer granted with siteCount=%d libraryCount=%d", tc.siteCount, tc.libraryCount) + } + }) + } +} diff --git a/internal/info.go b/internal/info.go index 99fd54b59..9d79070e5 100644 --- a/internal/info.go +++ b/internal/info.go @@ -91,18 +91,23 @@ func RegisterInfoEndpoint(pb *pocketbase.PocketBase) error { siteCap := getCap(pb, "site_cap", "PRIMO_SITE_CAP") editorCap := getCap(pb, "editor_cap", "PRIMO_EDITOR_CAP") siteCount, _ := pb.CountRecords("sites") + // library_block_count lets the setup screen show what a fresh + // `primo deploy` just seeded ("1 site · 12 library blocks loaded") + // before the operator creates their account. + libraryBlockCount, _ := pb.CountRecords("library_symbols") return requestEvent.JSON(200, struct { - Id string `json:"id"` - Version string `json:"version"` - TelemetryEnabled bool `json:"telemetry_enabled"` - SMTPEnabled bool `json:"smtp_enabled"` - HostedMode bool `json:"hosted_mode"` - BillingURL string `json:"billing_url,omitempty"` - DevMode bool `json:"dev_mode"` - SiteCap int `json:"site_cap,omitempty"` - SiteCount int64 `json:"site_count"` - EditorCap int `json:"editor_cap,omitempty"` + Id string `json:"id"` + Version string `json:"version"` + TelemetryEnabled bool `json:"telemetry_enabled"` + SMTPEnabled bool `json:"smtp_enabled"` + HostedMode bool `json:"hosted_mode"` + BillingURL string `json:"billing_url,omitempty"` + DevMode bool `json:"dev_mode"` + SiteCap int `json:"site_cap,omitempty"` + SiteCount int64 `json:"site_count"` + LibraryBlockCount int64 `json:"library_block_count"` + EditorCap int `json:"editor_cap,omitempty"` // Domain provider + base domain let the editor pick the // connect-domain flow: "railway" runs the attach+poll flow, // "manual" shows generic DNS guidance. base_domain (if set) @@ -110,18 +115,19 @@ func RegisterInfoEndpoint(pb *pocketbase.PocketBase) error { DomainProvider string `json:"domain_provider"` BaseDomain string `json:"base_domain,omitempty"` }{ - Id: id, - Version: version, - TelemetryEnabled: false, // Analytics disabled - SMTPEnabled: smtpEnabled, - HostedMode: isHostedMode(), - BillingURL: os.Getenv("PRIMO_BILLING_URL"), - DevMode: DevMode, - SiteCap: siteCap, - SiteCount: siteCount, - EditorCap: editorCap, - DomainProvider: getDomainProvider().Name(), - BaseDomain: baseDomain(), + Id: id, + Version: version, + TelemetryEnabled: false, // Analytics disabled + SMTPEnabled: smtpEnabled, + HostedMode: isHostedMode(), + BillingURL: os.Getenv("PRIMO_BILLING_URL"), + DevMode: DevMode, + SiteCap: siteCap, + SiteCount: siteCount, + LibraryBlockCount: libraryBlockCount, + EditorCap: editorCap, + DomainProvider: getDomainProvider().Name(), + BaseDomain: baseDomain(), }) }) diff --git a/src/lib/instance.ts b/src/lib/instance.ts index 97b92d680..731505a24 100644 --- a/src/lib/instance.ts +++ b/src/lib/instance.ts @@ -10,6 +10,7 @@ export type InstanceInfo = { dev_mode: boolean site_cap?: number site_count: number + library_block_count: number editor_cap?: number } diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte index 3342ebbb5..6d14eb1cb 100644 --- a/src/routes/setup/+page.svelte +++ b/src/routes/setup/+page.svelte @@ -3,6 +3,16 @@ import { Users } from '$lib/pocketbase/collections' import { Loader } from 'lucide-svelte' import { self } from '$lib/pocketbase/managers' + import { instance } from '$lib/instance' + + // A fresh `primo deploy` seeds sites + library before any account exists, + // so on first visit we can tell the operator what's already loaded. Falls + // back gracefully to 0 when the fields are absent (older server). + const seeded_sites = instance.site_count ?? 0 + const seeded_blocks = instance.library_block_count ?? 0 + const has_seeded_content = seeded_sites > 0 || seeded_blocks > 0 + + const count_label = (n: number, singular: string) => `${n} ${singular}${n === 1 ? '' : 's'}` let email = $state('') let password = $state('') @@ -92,7 +102,13 @@

Welcome to Primo

-

Create your admin account to get started

+

+ {#if has_seeded_content} + Your workspace is loaded. Create your admin account to start editing. + {:else} + Create your admin account to get started + {/if} +

{#if checking_setup} @@ -107,6 +123,20 @@
{/if} {:else} + {#if has_seeded_content} +
+

Already loaded on this server

+ +
+ {/if} + {#if error}
{error}
{/if} @@ -235,6 +265,41 @@ } } } + .seeded { + background-color: #2a2a2a; + border: 1px solid #444; + border-left: 2px solid #ff6b35; + border-radius: 4px; + padding: 1rem 1.25rem; + margin-bottom: 2rem; + + .seeded-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #b6b6b6; + margin: 0 0 0.5rem; + } + + .seeded-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.25rem; + + li { + font-size: 14px; + color: #dadada; + + &::before { + content: '✓'; + color: #4ade80; + margin-right: 0.5rem; + } + } + } + } .error { color: #f72228; margin-bottom: 1rem;