Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
155 changes: 155 additions & 0 deletions internal/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package internal

import (
"archive/zip"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)

// buildSiteZip creates an in-memory ZIP archive of the generated site files
// stored under sites/<host>/… and returns the buffer.
func buildSiteZip(pb *pocketbase.PocketBase, host string) (*bytes.Buffer, error) {
fs, err := pb.NewFilesystem()
if err != nil {
return nil, err
}

buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
prefix := "sites/" + host + "/"

var walk func(string) error
walk = func(pfx string) error {
entries, err := fs.List(pfx)
if err != nil {
return err
}
for _, ent := range entries {
if ent.IsDir {
if err := walk(strings.TrimSuffix(ent.Key, "/") + "/"); err != nil {
return err
}
continue
}
r, err := fs.GetReader(ent.Key)
if err != nil {
return err
}
f, err := zw.Create(strings.TrimPrefix(ent.Key, prefix))
if err != nil {
r.Close()
return err
}
if _, err := io.Copy(f, r); err != nil {
r.Close()
return err
}
r.Close()
}
return nil
}

if err := walk(prefix); err != nil {
zw.Close()
return nil, err
}
zw.Close()

return buf, nil
}

// RegisterExportEndpoints registers endpoints used for downloading and
// deploying a generated site. The handlers expect the caller to be
// authenticated and authorised to view the site in question (same checks as
// generate.go).
func RegisterExportEndpoints(pb *pocketbase.PocketBase) error {
pb.OnServe().BindFunc(func(se *core.ServeEvent) error {
// download the generated files as a zip archive
se.Router.GET("/api/palacms/site-zip/{siteId}", func(e *core.RequestEvent) error {
site, err := pb.FindRecordById("sites", e.Request.PathValue("siteId"))
if err != nil {
return e.NotFoundError("site not found", err)
}

info, _ := e.RequestInfo()
canAccess, _ := e.App.CanAccessRecord(site, info, site.Collection().ViewRule)
if !canAccess {
return e.ForbiddenError("", nil)
}

buf, err := buildSiteZip(pb, site.GetString("host"))
if err != nil {
return err
}

e.Response.Header().Set("Content-Type", "application/zip")
e.Response.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s.zip"`, site.GetString("host")))
e.Response.Write(buf.Bytes())
return nil
})

// trigger a deployment to Cloudflare Pages
se.Router.POST("/api/palacms/deploy/{siteId}", func(e *core.RequestEvent) error {
site, err := pb.FindRecordById("sites", e.Request.PathValue("siteId"))
if err != nil {
return e.NotFoundError("site not found", err)
}

info, _ := e.RequestInfo()
canAccess, _ := e.App.CanAccessRecord(site, info, site.Collection().UpdateRule)
if !canAccess {
return e.ForbiddenError("", nil)
}

// allow per-site overrides stored on the site record; fall back to
// environment variables for a global default.
acct := site.GetString("cfAccountId")
if acct == "" {
acct = os.Getenv("CF_ACCOUNT_ID")
}
proj := site.GetString("cfProjectName")
if proj == "" {
proj = os.Getenv("CF_PROJECT_NAME")
}
token := site.GetString("cfApiToken")
if token == "" {
token = os.Getenv("CF_API_TOKEN")
}
if acct == "" || proj == "" || token == "" {
return e.InternalServerError("cloudflare credentials missing", nil)
}

// The files are already generated and stored locally in PocketBase's data directory.
siteDir := filepath.Join(pb.DataDir(), "storage", "sites", site.GetString("host"))

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
cmd := exec.Command("npx", "-y", "wrangler@latest", "pages", "deploy", siteDir, "--project-name", proj, "--branch", "main")
cmd.Env = append(os.Environ(),
"CLOUDFLARE_ACCOUNT_ID="+acct,
"CLOUDFLARE_API_TOKEN="+token,
"WRANGLER_SEND_METRICS=false",
)

out, err := cmd.CombinedOutput()
if err != nil {
Comment thread
Amerlander marked this conversation as resolved.
Outdated
return e.InternalServerError("cloudflare deployment failed: "+string(out), err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return e.JSON(200, map[string]any{
"status": "deployed",
"url": "https://" + proj + ".pages.dev",
})
})

return se.Next()
})
return nil
}
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,10 @@ func setup(pb *pocketbase.PocketBase) error {
return err
}

// endpoints for downloading/deploying generated sites
if err := internal.RegisterExportEndpoints(pb); err != nil {
return err
}

return nil
}
49 changes: 49 additions & 0 deletions migrations/1766436666_add_cf_fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package migrations

import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)

func init() {
m.Register(
func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("sites")
if err != nil {
return err
}

// Add the Cloudflare deployment fields
collection.Fields.Add(
&core.TextField{
Name: "cfAccountId",
Required: false,
},
&core.TextField{
Name: "cfProjectName",
Required: false,
},
&core.TextField{
Name: "cfApiToken",
Required: false,
Hidden: true,
},
Comment on lines +26 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /root/repo && git ls-files | grep -E "(migrations|pocketbase)" | head -20

Repository: palacms/palacms

Length of output: 119


🌐 Web query:

PocketBase core.TextField Hidden true encryption at rest

💡 Result:

In PocketBase, core.TextField{Hidden: true} does not provide encryption-at-rest. It only omits the field from the API response output (“Hidden hides the field from the API response”). [1][2]

PocketBase’s maintainer confirms there is no built-in encryption for database (record) content at rest; if you need it, you must implement it yourself (encrypt specific field values in your app) or use external tooling (e.g., encrypt the DB file / disk, SQLite encryption extensions, etc.). [3]

PocketBase does have an optional “settings encryption” feature (for app settings like SMTP password / S3 credentials), enabled via --encryptionEnv=YOUR_ENV_VAR with a 32-char key—but that is separate from record field storage and doesn’t make Hidden fields encrypted on disk. [4]

Sources
[1] PocketBase JSVM reference – core.TextField.hidden (“Hidden hides the field from the API response”) (pocketbase.io)
[2] Go package docs (github.com/pocketbase/pocketbase/core) – Hidden field comment (pkg.go.dev)
[3] PocketBase GitHub Discussion #1961 – “There is no built-in encryption for the db content” (github.com)
[4] PocketBase “Going to production” docs – “Enable settings encryption” with --encryptionEnv (pocketbase.io)

Citations:


🌐 Web query:

PocketBase field encryption security hidden fields

💡 Result:

PocketBase “Hidden” fields: what they do (and don’t)

  • “Hidden” is primarily a serialization/visibility control, meant to prevent “regular user” access to a field’s value in the public API output (and you can also hide/unhide dynamically at runtime via record.Hide(...) / record.Unhide(...), typically in OnRecordEnrich). (pocketbase.io)
  • Hidden ≠ encrypted: the value is still stored in the database as usual; “Hidden” doesn’t provide cryptographic protection. (So if the DB file/backups leak, hidden fields leak too.)

Field/value encryption support in PocketBase

  • PocketBase does not provide built-in encryption for database record content / field-level encryption. If you need encrypted fields, you must implement it yourself (e.g., encrypt before save, decrypt after read) in your app logic/hooks, and manage keys securely. (github.com)
  • PocketBase does support encrypting PocketBase settings (where secrets like SMTP/S3 creds may live) via an env key + --encryptionEnv=.... (pocketbase.io)
  • If you’re using PocketBase’s JS hooks/runtime, there’s a provided encrypt(data, key) helper in the JSVM security API (you still own key management and the overall scheme). (pocketbase.io)

Security implications / best practice

  • Treat API Rules (collection/list/view/create/update/delete rules) as your real access-control boundary; use Hidden to reduce accidental exposure in responses, not as a security control.
  • For truly sensitive data, prefer (a) a separate collection with very strict rules, (b) storing only hashes/tokens when possible, or (c) app-managed encryption (with keys not stored alongside the DB).

Note on auth collections

  • PocketBase auth collections include system fields like password (bcrypt-hashed) and tokenKey, and those are marked Hidden by default. (deepwiki.com)

Citations:


Do not store Cloudflare API tokens as plain-text site fields.

Hidden: true only prevents the field from appearing in API responses; it provides no encryption at rest. The token value remains stored in the database in plaintext and is exposed in any database dump or backup. This unnecessary blast radius for a long-lived credential can be eliminated.

Store API tokens in a separate server-side secret store or encrypted-at-rest secret table with strict access controls. Alternatively, consider storing only a non-sensitive site identifier and retrieving the actual token from a secure external service at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@migrations/1766436666_add_cf_fields.go` around lines 26 - 30, The migration
adds a plain-text site field cfApiToken (core.TextField with Name "cfApiToken")
which stores a long-lived Cloudflare API token unencrypted; remove this field
from the site schema and instead persist the token in a server-side secret store
or an encrypted-at-rest secrets table with strict access controls, or store only
a non-sensitive site identifier and fetch the token at runtime from a secure
service; update the migration that creates the core.TextField entry for
"cfApiToken" to instead create or reference the secure secret mechanism and add
notes in the code where the token is retrieved so callers use the secret store
API rather than reading a site field.

)

return app.Save(collection)
},
func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("sites")
if err != nil {
return err
}

// Remove the Cloudflare deployment fields
collection.Fields.RemoveByName("cfAccountId")
collection.Fields.RemoveByName("cfProjectName")
collection.Fields.RemoveByName("cfApiToken")

return app.Save(collection)
},
)
}
Loading