feat: persistent storage for Deno static sites
Build / build (push) Successful in 10m21s

- Add storage_enabled and storage_limit_mb columns to static_sites.
- Create/attach Docker volumes (tinyforge-site-{name}-data) for Deno
  sites with storage enabled, mounted at /app/data.
- Grant --allow-write=/app/data in Deno container CMD.
- Add storage usage API endpoint (GET /api/sites/{id}/storage).
- Show storage section in site detail page with usage bar.
- Add storage toggle and limit field to new site wizard.
- Use ConfirmDialog for secret deletion instead of inline delete.
This commit is contained in:
2026-04-13 00:12:51 +03:00
parent 9ec25a8d5a
commit b622384774
12 changed files with 327 additions and 15 deletions
+1
View File
@@ -255,6 +255,7 @@ func (s *Server) Router() chi.Router {
r.Route("/sites/{id}", func(r chi.Router) {
r.Get("/", s.getStaticSite)
r.Get("/secrets", s.listStaticSiteSecrets)
r.Get("/storage", s.getStaticSiteStorage)
// Admin-only mutations.
r.Group(func(r chi.Router) {
+42
View File
@@ -61,6 +61,8 @@ type createStaticSiteRequest struct {
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
}
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
@@ -109,6 +111,8 @@ func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
RenderMarkdown: req.RenderMarkdown,
SyncTrigger: req.SyncTrigger,
TagPattern: req.TagPattern,
StorageEnabled: req.StorageEnabled,
StorageLimitMB: req.StorageLimitMB,
Status: "idle",
}
@@ -174,6 +178,8 @@ func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
}
existing.RenderMarkdown = req.RenderMarkdown
existing.TagPattern = req.TagPattern
existing.StorageEnabled = req.StorageEnabled
existing.StorageLimitMB = req.StorageLimitMB
// Update access token only if a new one is provided.
if req.AccessToken != "" {
@@ -604,6 +610,42 @@ func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
}
// ── Storage Usage ──────────────────────────────────────────────────
func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
if !site.StorageEnabled {
respondJSON(w, http.StatusOK, map[string]interface{}{
"enabled": false,
"used_bytes": 0,
"limit_mb": 0,
})
return
}
usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to inspect storage usage")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"used_bytes": usage.UsedBytes,
"limit_mb": site.StorageLimitMB,
})
}
// maskToken returns a masked version of a token string for API responses.
func maskToken(token string) string {
if token == "" {
+44
View File
@@ -0,0 +1,44 @@
package docker
import (
"bytes"
"context"
"fmt"
"io"
"github.com/moby/moby/client"
)
// ExecInContainer runs a command inside a running container and returns the combined output.
func (c *Client) ExecInContainer(ctx context.Context, containerID string, cmd []string) (string, error) {
exec, err := c.api.ExecCreate(ctx, containerID, client.ExecCreateOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", fmt.Errorf("exec create: %w", err)
}
attach, err := c.api.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{})
if err != nil {
return "", fmt.Errorf("exec attach: %w", err)
}
defer attach.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, attach.Reader); err != nil {
return "", fmt.Errorf("exec read output: %w", err)
}
// Check exit code.
inspect, err := c.api.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{})
if err != nil {
return buf.String(), nil // best effort — return output even if inspect fails
}
if inspect.ExitCode != 0 {
return "", fmt.Errorf("exec exited with code %d", inspect.ExitCode)
}
return buf.String(), nil
}
+83
View File
@@ -0,0 +1,83 @@
package docker
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/moby/moby/client"
)
// SiteVolumeName returns the deterministic volume name for a static site.
func SiteVolumeName(siteName string) string {
return fmt.Sprintf("tinyforge-site-%s-data", siteName)
}
// EnsureSiteVolume creates a named volume for a static site if it doesn't already exist.
// Returns the volume name.
func (c *Client) EnsureSiteVolume(ctx context.Context, siteName string) (string, error) {
name := SiteVolumeName(siteName)
// Check if volume already exists.
_, err := c.api.VolumeInspect(ctx, name, client.VolumeInspectOptions{})
if err == nil {
return name, nil
}
// Create the volume.
_, err = c.api.VolumeCreate(ctx, client.VolumeCreateOptions{
Name: name,
Labels: map[string]string{
"tinyforge.static-site": "true",
"tinyforge.site-name": siteName,
},
})
if err != nil {
return "", fmt.Errorf("create volume %s: %w", name, err)
}
return name, nil
}
// RemoveSiteVolume removes the named volume for a static site.
func (c *Client) RemoveSiteVolume(ctx context.Context, siteName string) error {
name := SiteVolumeName(siteName)
_, err := c.api.VolumeRemove(ctx, name, client.VolumeRemoveOptions{Force: true})
if err != nil {
return fmt.Errorf("remove volume %s: %w", name, err)
}
return nil
}
// StorageUsage holds the disk usage info for a site volume.
type StorageUsage struct {
UsedBytes int64 `json:"used_bytes"`
}
// InspectSiteStorageUsage execs `du -sb /app/data` inside the container to get usage.
// Returns 0 if the container is not running or the path doesn't exist.
func (c *Client) InspectSiteStorageUsage(ctx context.Context, containerID string) (StorageUsage, error) {
if containerID == "" {
return StorageUsage{}, nil
}
output, err := c.ExecInContainer(ctx, containerID, []string{"du", "-sb", "/app/data"})
if err != nil {
// Container not running or path doesn't exist — return zero.
return StorageUsage{}, nil
}
// du output: "12345\t/app/data\n"
parts := strings.Fields(strings.TrimSpace(output))
if len(parts) == 0 {
return StorageUsage{}, nil
}
bytes, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return StorageUsage{}, nil
}
return StorageUsage{UsedBytes: bytes}, nil
}
+5 -1
View File
@@ -231,15 +231,19 @@ COPY public/ /app/public/
COPY api/ /app/api/
COPY router.ts /app/router.ts
# Create data directory for persistent storage (mounted as volume when enabled).
RUN mkdir -p /app/data
# Cache dependencies.
RUN deno install --entrypoint router.ts
EXPOSE 8000
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "router.ts"]
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write=/app/data", "--allow-env", "router.ts"]
`
}
// GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites.
func GenerateStaticDockerfile() string {
return `FROM nginx:alpine
+27
View File
@@ -10,6 +10,8 @@ import (
"strconv"
"time"
"github.com/moby/moby/api/types/mount"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
@@ -198,6 +200,22 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
containerName := fmt.Sprintf("dw-site-%s", site.Name)
// Prepare volume mounts for persistent storage.
var mounts []mount.Mount
if site.StorageEnabled && mode == "deno" {
volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name)
if volErr != nil {
slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr)
} else {
mounts = append(mounts, mount.Mount{
Type: mount.TypeVolume,
Source: volName,
Target: "/app/data",
})
slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName)
}
}
// Create and start new container.
containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{
Name: containerName,
@@ -206,6 +224,7 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
ExposedPorts: []string{containerPort + "/tcp"},
NetworkName: networkName,
NetworkID: networkID,
Mounts: mounts,
Labels: map[string]string{
"tinyforge.static-site": site.ID,
"tinyforge.static-site-name": site.Name,
@@ -229,6 +248,7 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
ExposedPorts: []string{containerPort + "/tcp"},
NetworkName: networkName,
NetworkID: networkID,
Mounts: mounts,
Labels: map[string]string{
"tinyforge.static-site": site.ID,
"tinyforge.static-site-name": site.Name,
@@ -346,6 +366,13 @@ func (m *Manager) Remove(ctx context.Context, siteID string) error {
}
}
// Remove storage volume if it was enabled (best effort).
if site.StorageEnabled {
if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil {
slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err)
}
}
return nil
}
+2
View File
@@ -217,6 +217,8 @@ type StaticSite struct {
LastSyncAt string `json:"last_sync_at"`
LastCommitSHA string `json:"last_commit_sha"`
Error string `json:"error"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+14 -8
View File
@@ -12,7 +12,7 @@ import (
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
created_at, updated_at`
storage_enabled, storage_limit_mb, created_at, updated_at`
// CreateStaticSite inserts a new static site and returns it.
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
@@ -22,12 +22,13 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
_, err := s.db.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, site.CreatedAt, site.UpdatedAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
@@ -100,11 +101,12 @@ func (s *Store) UpdateStaticSite(site StaticSite) error {
result, err := s.db.Exec(
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
sync_trigger=?, tag_pattern=?, updated_at=?
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, updated_at=?
WHERE id=?`,
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.UpdatedAt, site.ID,
)
if err != nil {
@@ -168,35 +170,39 @@ func (s *Store) DeleteStaticSite(id string) error {
// scanStaticSiteRow scans a static site from a *sql.Row.
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
var site StaticSite
var renderMarkdown int
var renderMarkdown, storageEnabled int
err := row.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, err
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
return site, nil
}
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
var site StaticSite
var renderMarkdown int
var renderMarkdown, storageEnabled int
err := rows.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
return site, nil
}
+3
View File
@@ -125,6 +125,9 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
// Add provider column to static_sites (2026-04-11).
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
// Add persistent storage columns to static_sites (2026-04-12).
`ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
}
for _, m := range migrations {