Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion internal/import_library.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
elemdos marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions internal/import_library_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
50 changes: 28 additions & 22 deletions internal/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,37 +91,43 @@ 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)
// means new sites get a live subdomain and is shown as a hint.
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(),
})
})

Expand Down
1 change: 1 addition & 0 deletions src/lib/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type InstanceInfo = {
dev_mode: boolean
site_cap?: number
site_count: number
library_block_count: number
editor_cap?: number
}

Expand Down
67 changes: 66 additions & 1 deletion src/routes/setup/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down Expand Up @@ -92,7 +102,13 @@
<div class="box">
<header>
<h1>Welcome to Primo</h1>
<p class="subtitle">Create your admin account to get started</p>
<p class="subtitle">
{#if has_seeded_content}
Your workspace is loaded. Create your admin account to start editing.
{:else}
Create your admin account to get started
{/if}
</p>
</header>

{#if checking_setup}
Expand All @@ -107,6 +123,20 @@
</div>
{/if}
{:else}
{#if has_seeded_content}
<div class="seeded" data-test-id="seeded-content">
<p class="seeded-label">Already loaded on this server</p>
<ul class="seeded-list">
{#if seeded_sites > 0}
<li>{count_label(seeded_sites, 'site')}</li>
{/if}
{#if seeded_blocks > 0}
<li>{count_label(seeded_blocks, 'library block')}</li>
{/if}
</ul>
</div>
{/if}

{#if error}
<div class="error">{error}</div>
{/if}
Expand Down Expand Up @@ -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;
Expand Down