feat: volume file browser with absolute scope
Volume browser for inspecting, downloading, and uploading files. Absolute volume scope with allowlist security for external mounts.
This commit is contained in:
@@ -139,6 +139,8 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||||
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
||||||
r.Get("/volumes", s.listVolumes)
|
r.Get("/volumes", s.listVolumes)
|
||||||
|
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
||||||
|
r.Get("/volumes/{volId}/download", s.downloadVolume)
|
||||||
|
|
||||||
// Admin-only project mutations.
|
// Admin-only project mutations.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -169,6 +171,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Post("/volumes", s.createVolume)
|
r.Post("/volumes", s.createVolume)
|
||||||
r.Put("/volumes/{volId}", s.updateVolume)
|
r.Put("/volumes/{volId}", s.updateVolume)
|
||||||
r.Delete("/volumes/{volId}", s.deleteVolume)
|
r.Delete("/volumes/{volId}", s.deleteVolume)
|
||||||
|
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.Get("/deploys", s.listDeploys)
|
r.Get("/deploys", s.listDeploys)
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
"github.com/alexei/docker-watcher/internal/npm"
|
"github.com/alexei/docker-watcher/internal/npm"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
"github.com/alexei/docker-watcher/internal/volume"
|
||||||
"github.com/alexei/docker-watcher/internal/webhook"
|
"github.com/alexei/docker-watcher/internal/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,8 +26,9 @@ type settingsRequest struct {
|
|||||||
NpmEmail string `json:"npm_email"`
|
NpmEmail string `json:"npm_email"`
|
||||||
NpmPassword string `json:"npm_password"`
|
NpmPassword string `json:"npm_password"`
|
||||||
PollingInterval string `json:"polling_interval"`
|
PollingInterval string `json:"polling_interval"`
|
||||||
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
|
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
|
||||||
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
|
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
|
||||||
|
AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSettings handles GET /api/settings.
|
// getSettings handles GET /api/settings.
|
||||||
@@ -48,8 +51,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"has_npm_password": settings.NpmPassword != "",
|
"has_npm_password": settings.NpmPassword != "",
|
||||||
"polling_interval": settings.PollingInterval,
|
"polling_interval": settings.PollingInterval,
|
||||||
"ssl_certificate_id": settings.SSLCertificateID,
|
"ssl_certificate_id": settings.SSLCertificateID,
|
||||||
"stale_threshold_days": settings.StaleThresholdDays,
|
"stale_threshold_days": settings.StaleThresholdDays,
|
||||||
"updated_at": settings.UpdatedAt,
|
"allowed_volume_paths": settings.AllowedVolumePaths,
|
||||||
|
"updated_at": settings.UpdatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +114,23 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
updated.StaleThresholdDays = *req.StaleThresholdDays
|
updated.StaleThresholdDays = *req.StaleThresholdDays
|
||||||
}
|
}
|
||||||
|
if req.AllowedVolumePaths != nil {
|
||||||
|
// Validate it's valid JSON array of strings.
|
||||||
|
paths, err := volume.ParseAllowedPaths(*req.AllowedVolumePaths)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "allowed_volume_paths must be a JSON array of strings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate each path is absolute.
|
||||||
|
for _, p := range paths {
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
respondError(w, http.StatusBadRequest, "each allowed volume path must be absolute")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated.AllowedVolumePaths = *req.AllowedVolumePaths
|
||||||
|
_ = paths // validated
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.store.UpdateSettings(updated); err != nil {
|
if err := s.store.UpdateSettings(updated); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
"github.com/alexei/docker-watcher/internal/volume"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r == '"' || r == '\\' || r == '\n' || r == '\r' {
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxUploadSize = 100 * 1024 * 1024 // 100MB
|
||||||
|
|
||||||
|
// resolveVolumeRoot looks up a volume and resolves its host path.
|
||||||
|
func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
volID := chi.URLParam(r, "volId")
|
||||||
|
|
||||||
|
proj, err := s.store.GetProjectByID(projectID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
respondNotFound(w, "project")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
vol, err := s.store.GetVolumeByID(volID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
respondNotFound(w, "volume")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
slog.Error("failed to get volume", "volume_id", volID, "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to get volume")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify volume belongs to this project.
|
||||||
|
if vol.ProjectID != projectID {
|
||||||
|
respondNotFound(w, "volume")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if vol.Scope == "ephemeral" {
|
||||||
|
respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := s.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get settings", "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to get settings")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
params := volume.ResolveParams{
|
||||||
|
BasePath: settings.BaseVolumePath,
|
||||||
|
ProjectName: proj.Name,
|
||||||
|
StageName: q.Get("stage"),
|
||||||
|
ImageTag: q.Get("tag"),
|
||||||
|
AllowedVolumePaths: settings.AllowedVolumePaths,
|
||||||
|
}
|
||||||
|
|
||||||
|
rootPath, err := volume.ResolvePath(vol, params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootPath, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag=
|
||||||
|
func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rootPath, ok := s.resolveVolumeRoot(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := r.URL.Query().Get("path")
|
||||||
|
entries, err := volume.ListDir(rootPath, relPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list directory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"path": relPath,
|
||||||
|
"entries": entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag=
|
||||||
|
// Downloads a single file directly, or a directory/root as a zip archive.
|
||||||
|
func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rootPath, ok := s.resolveVolumeRoot(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := r.URL.Query().Get("path")
|
||||||
|
|
||||||
|
// If path is empty or points to a directory, serve as zip.
|
||||||
|
if relPath == "" {
|
||||||
|
s.serveZip(w, rootPath, "", "volume")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a file or directory.
|
||||||
|
f, info, err := volume.OpenFile(rootPath, relPath)
|
||||||
|
if err != nil {
|
||||||
|
// Might be a directory — try zip.
|
||||||
|
entries, listErr := volume.ListDir(rootPath, relPath)
|
||||||
|
if listErr == nil && entries != nil {
|
||||||
|
name := filepath.Base(relPath)
|
||||||
|
s.serveZip(w, rootPath, relPath, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err)
|
||||||
|
respondError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Serve single file with forced download.
|
||||||
|
safeName := sanitizeFilename(filepath.Base(relPath))
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName))
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
io.Copy(w, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) {
|
||||||
|
safeName := sanitizeFilename(name)
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if err := volume.WriteZip(rootPath, relPath, w); err != nil {
|
||||||
|
slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag=
|
||||||
|
// Accepts multipart form uploads. Overrides the global body limit for large files.
|
||||||
|
func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Override the global 1MB body limit for uploads.
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||||
|
|
||||||
|
rootPath, ok := s.resolveVolumeRoot(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "upload too large (max 100MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := r.URL.Query().Get("path")
|
||||||
|
uploaded := []string{}
|
||||||
|
|
||||||
|
for _, fileHeaders := range r.MultipartForm.File {
|
||||||
|
for _, fh := range fileHeaders {
|
||||||
|
f, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to open upload", "filename", fh.Filename, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip directory components from filename to prevent directory creation attacks.
|
||||||
|
targetRel := filepath.Join(relPath, filepath.Base(fh.Filename))
|
||||||
|
if err := volume.SaveFile(rootPath, targetRel, f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
slog.Error("failed to save upload", "filename", fh.Filename, "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
uploaded = append(uploaded, fh.Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"uploaded": uploaded,
|
||||||
|
"count": len(uploaded),
|
||||||
|
})
|
||||||
|
}
|
||||||
+59
-5
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
"github.com/alexei/docker-watcher/internal/volume"
|
||||||
)
|
)
|
||||||
|
|
||||||
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
|
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
|
||||||
@@ -35,12 +36,13 @@ type volumeRequest struct {
|
|||||||
var validScopes = map[string]bool{
|
var validScopes = map[string]bool{
|
||||||
"instance": true, "stage": true, "project": true,
|
"instance": true, "stage": true, "project": true,
|
||||||
"project_named": true, "named": true, "ephemeral": true,
|
"project_named": true, "named": true, "ephemeral": true,
|
||||||
|
"absolute": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateVolumeScope validates the scope and name combination.
|
// validateVolumeScope validates the scope, name, and source combination.
|
||||||
func validateVolumeScope(scope, name string) string {
|
func validateVolumeScope(scope, name, source, allowedPathsJSON string) string {
|
||||||
if !validScopes[scope] {
|
if !validScopes[scope] {
|
||||||
return "scope must be one of: instance, stage, project, project_named, named, ephemeral"
|
return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute"
|
||||||
}
|
}
|
||||||
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
|
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
|
||||||
return "name is required for " + scope + " scope"
|
return "name is required for " + scope + " scope"
|
||||||
@@ -48,6 +50,34 @@ func validateVolumeScope(scope, name string) string {
|
|||||||
if name != "" && !safeNamePattern.MatchString(name) {
|
if name != "" && !safeNamePattern.MatchString(name) {
|
||||||
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
|
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
|
||||||
}
|
}
|
||||||
|
if scope == "absolute" {
|
||||||
|
if source == "" {
|
||||||
|
return "source path is required for absolute scope"
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(source) {
|
||||||
|
return "absolute scope requires an absolute source path"
|
||||||
|
}
|
||||||
|
// Validate against allowlist.
|
||||||
|
allowed, err := volume.ParseAllowedPaths(allowedPathsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return "failed to parse allowed volume paths"
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return "absolute volume paths are disabled — configure allowed paths in settings first"
|
||||||
|
}
|
||||||
|
matched := false
|
||||||
|
cleanSource := filepath.Clean(source)
|
||||||
|
for _, prefix := range allowed {
|
||||||
|
cleanPrefix := filepath.Clean(prefix)
|
||||||
|
if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return "source path is not under any allowed volume path"
|
||||||
|
}
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +129,12 @@ func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
|
|||||||
NeedsName: false,
|
NeedsName: false,
|
||||||
PathExample: "(tmpfs — no host path)",
|
PathExample: "(tmpfs — no host path)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Scope: "absolute",
|
||||||
|
Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.",
|
||||||
|
NeedsName: false,
|
||||||
|
PathExample: "/mnt/nfs/data (must match allowed paths)",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, scopes)
|
respondJSON(w, http.StatusOK, scopes)
|
||||||
}
|
}
|
||||||
@@ -179,7 +215,15 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" {
|
// Fetch settings for absolute path allowlist validation.
|
||||||
|
settings, err := s.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get settings for volume validation", "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to validate volume")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" {
|
||||||
respondError(w, http.StatusBadRequest, errMsg)
|
respondError(w, http.StatusBadRequest, errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -235,7 +279,17 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
|
|||||||
updated.Target = req.Target
|
updated.Target = req.Target
|
||||||
}
|
}
|
||||||
if req.Scope != "" {
|
if req.Scope != "" {
|
||||||
if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" {
|
settings, err := s.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get settings for volume validation", "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to validate volume")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source := updated.Source
|
||||||
|
if req.Source != "" {
|
||||||
|
source = req.Source
|
||||||
|
}
|
||||||
|
if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" {
|
||||||
respondError(w, http.StatusBadRequest, errMsg)
|
respondError(w, http.StatusBadRequest, errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
"github.com/alexei/docker-watcher/internal/notify"
|
"github.com/alexei/docker-watcher/internal/notify"
|
||||||
"github.com/alexei/docker-watcher/internal/npm"
|
"github.com/alexei/docker-watcher/internal/npm"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
"github.com/alexei/docker-watcher/internal/volume"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -619,13 +619,7 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
|
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
|
||||||
// Resolves the host path based on the volume's scope:
|
// Uses the shared volume.ResolvePath for path resolution.
|
||||||
// - instance: {base}/{project}/{stage}-{tag}/{source}
|
|
||||||
// - stage: {base}/{project}/{stage}/{source}
|
|
||||||
// - project: {base}/{project}/{source}
|
|
||||||
// - project_named: {base}/{project}/_named/{name}/{source}
|
|
||||||
// - named: {base}/_named/{name}/{source}
|
|
||||||
// - ephemeral: tmpfs mount (no host path)
|
|
||||||
func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount {
|
func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount {
|
||||||
vols, err := d.store.GetVolumesByProjectID(projectID)
|
vols, err := d.store.GetVolumesByProjectID(projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -637,9 +631,15 @@ func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageT
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
params := volume.ResolveParams{
|
||||||
|
BasePath: basePath,
|
||||||
|
ProjectName: projectName,
|
||||||
|
StageName: stageName,
|
||||||
|
ImageTag: imageTag,
|
||||||
|
}
|
||||||
|
|
||||||
mounts := make([]mount.Mount, 0, len(vols))
|
mounts := make([]mount.Mount, 0, len(vols))
|
||||||
for _, vol := range vols {
|
for _, vol := range vols {
|
||||||
// Resolve scope — use Scope field, fall back to Mode for backward compat.
|
|
||||||
scope := vol.Scope
|
scope := vol.Scope
|
||||||
if scope == "" {
|
if scope == "" {
|
||||||
switch vol.Mode {
|
switch vol.Mode {
|
||||||
@@ -659,22 +659,10 @@ func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageT
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build host path based on scope.
|
source, err := volume.ResolvePath(vol, params)
|
||||||
var source string
|
if err != nil {
|
||||||
switch scope {
|
slog.Warn("resolve volume path", "volume_id", vol.ID, "error", err)
|
||||||
case "instance":
|
continue
|
||||||
source = filepath.Join(basePath, projectName, fmt.Sprintf("%s-%s", stageName, imageTag), vol.Source)
|
|
||||||
case "stage":
|
|
||||||
source = filepath.Join(basePath, projectName, stageName, vol.Source)
|
|
||||||
case "project":
|
|
||||||
source = filepath.Join(basePath, projectName, vol.Source)
|
|
||||||
case "project_named":
|
|
||||||
source = filepath.Join(basePath, projectName, "_named", vol.Name, vol.Source)
|
|
||||||
case "named":
|
|
||||||
source = filepath.Join(basePath, "_named", vol.Name, vol.Source)
|
|
||||||
default:
|
|
||||||
// Fallback: treat as project scope.
|
|
||||||
source = filepath.Join(basePath, projectName, vol.Source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mounts = append(mounts, mount.Mount{
|
mounts = append(mounts, mount.Mount{
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Settings struct {
|
|||||||
BaseVolumePath string `json:"base_volume_path"`
|
BaseVolumePath string `json:"base_volume_path"`
|
||||||
SSLCertificateID int `json:"ssl_certificate_id"`
|
SSLCertificateID int `json:"ssl_certificate_id"`
|
||||||
StaleThresholdDays int `json:"stale_threshold_days"`
|
StaleThresholdDays int `json:"stale_threshold_days"`
|
||||||
|
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +121,14 @@ const (
|
|||||||
VolumeScopeProjectNamed VolumeScope = "project_named"
|
VolumeScopeProjectNamed VolumeScope = "project_named"
|
||||||
VolumeScopeNamed VolumeScope = "named"
|
VolumeScopeNamed VolumeScope = "named"
|
||||||
VolumeScopeEphemeral VolumeScope = "ephemeral"
|
VolumeScopeEphemeral VolumeScope = "ephemeral"
|
||||||
|
VolumeScopeAbsolute VolumeScope = "absolute"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidVolumeScopes contains all valid scope values for validation.
|
// ValidVolumeScopes contains all valid scope values for validation.
|
||||||
var ValidVolumeScopes = []VolumeScope{
|
var ValidVolumeScopes = []VolumeScope{
|
||||||
VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject,
|
VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject,
|
||||||
VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral,
|
VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral,
|
||||||
|
VolumeScopeAbsolute,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidVolumeScope returns true if the given string is a valid scope.
|
// IsValidVolumeScope returns true if the given string is a valid scope.
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
var st Settings
|
var st Settings
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
|
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
|
||||||
npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, updated_at
|
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
|
||||||
|
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
||||||
|
allowed_volume_paths, updated_at
|
||||||
FROM settings WHERE id = 1`,
|
FROM settings WHERE id = 1`,
|
||||||
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
||||||
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.UpdatedAt)
|
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
|
||||||
|
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
||||||
|
&st.AllowedVolumePaths, &st.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, fmt.Errorf("query settings: %w", err)
|
return Settings{}, fmt.Errorf("query settings: %w", err)
|
||||||
}
|
}
|
||||||
@@ -25,10 +29,14 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE settings SET
|
`UPDATE settings SET
|
||||||
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
||||||
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, updated_at=?
|
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
|
||||||
|
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
||||||
|
allowed_volume_paths=?, updated_at=?
|
||||||
WHERE id = 1`,
|
WHERE id = 1`,
|
||||||
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
||||||
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.UpdatedAt,
|
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
|
||||||
|
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
||||||
|
st.AllowedVolumePaths, st.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update settings: %w", err)
|
return fmt.Errorf("update settings: %w", err)
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ func (s *Store) runMigrations() error {
|
|||||||
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
|
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
|
||||||
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
|
||||||
|
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
|
||||||
|
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileEntry represents a single file or directory in a volume listing.
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModTime time.Time `json:"mod_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir returns the contents of a directory within a volume root.
|
||||||
|
// The relativePath is validated to stay within rootPath.
|
||||||
|
func ListDir(rootPath, relativePath string) ([]FileEntry, error) {
|
||||||
|
absPath, err := safePath(rootPath, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []FileEntry{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("stat directory: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("path is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]FileEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, FileEntry{
|
||||||
|
Name: e.Name(),
|
||||||
|
IsDir: e.IsDir(),
|
||||||
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFile opens a file within the volume root for reading.
|
||||||
|
// The caller is responsible for closing the returned file.
|
||||||
|
func OpenFile(rootPath, relativePath string) (*os.File, os.FileInfo, error) {
|
||||||
|
absPath, err := safePath(rootPath, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("stat file: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, nil, fmt.Errorf("path is a directory, use download as zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("open file: %w", err)
|
||||||
|
}
|
||||||
|
return f, info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteZip writes the contents of a directory (or the entire root) as a zip archive to w.
|
||||||
|
func WriteZip(rootPath, relativePath string, w io.Writer) error {
|
||||||
|
absPath, err := safePath(rootPath, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat path: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("path is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
zw := zip.NewWriter(w)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
return filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(absPath, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Use forward slashes in zip entries.
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
_, err := zw.Create(rel + "/")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = rel
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
|
||||||
|
writer, err := zw.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, f)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFile writes uploaded content to a file within the volume root.
|
||||||
|
func SaveFile(rootPath, relativePath string, r io.Reader) error {
|
||||||
|
absPath, err := safePath(rootPath, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists.
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(f, r); err != nil {
|
||||||
|
return fmt.Errorf("write file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safePath resolves a relative path within rootPath and validates it doesn't escape.
|
||||||
|
// Resolves symlinks to prevent symlink-based traversal attacks.
|
||||||
|
func safePath(rootPath, relativePath string) (string, error) {
|
||||||
|
if relativePath == "" {
|
||||||
|
return rootPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and ensure no traversal.
|
||||||
|
cleaned := filepath.Clean(relativePath)
|
||||||
|
if strings.Contains(cleaned, "..") {
|
||||||
|
return "", fmt.Errorf("path traversal not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath := filepath.Join(rootPath, cleaned)
|
||||||
|
|
||||||
|
// Resolve the root path (follow symlinks in the root itself).
|
||||||
|
absRoot, err := filepath.Abs(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve root: %w", err)
|
||||||
|
}
|
||||||
|
if realRoot, err := filepath.EvalSymlinks(absRoot); err == nil {
|
||||||
|
absRoot = realRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the target path including symlinks.
|
||||||
|
absResolved, err := filepath.Abs(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve path: %w", err)
|
||||||
|
}
|
||||||
|
if realResolved, err := filepath.EvalSymlinks(absResolved); err == nil {
|
||||||
|
absResolved = realResolved
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(absResolved, absRoot) {
|
||||||
|
return "", fmt.Errorf("path traversal not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveParams holds the parameters needed to resolve a volume's host path.
|
||||||
|
type ResolveParams struct {
|
||||||
|
BasePath string
|
||||||
|
ProjectName string
|
||||||
|
StageName string // required for instance and stage scopes
|
||||||
|
ImageTag string // required for instance scope
|
||||||
|
AllowedVolumePaths string // JSON array of allowed absolute paths (from settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePath returns the absolute host path for a volume based on its scope.
|
||||||
|
// Returns an error for ephemeral volumes (no host path) or missing parameters.
|
||||||
|
func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
|
||||||
|
scope := vol.Scope
|
||||||
|
if scope == "" {
|
||||||
|
switch vol.Mode {
|
||||||
|
case "isolated":
|
||||||
|
scope = "instance"
|
||||||
|
default:
|
||||||
|
scope = "project"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope == "ephemeral" {
|
||||||
|
return "", fmt.Errorf("ephemeral volumes have no host path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope == "absolute" {
|
||||||
|
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch scope {
|
||||||
|
case "instance":
|
||||||
|
if params.StageName == "" || params.ImageTag == "" {
|
||||||
|
return "", fmt.Errorf("instance scope requires stage and tag parameters")
|
||||||
|
}
|
||||||
|
return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil
|
||||||
|
case "stage":
|
||||||
|
if params.StageName == "" {
|
||||||
|
return "", fmt.Errorf("stage scope requires stage parameter")
|
||||||
|
}
|
||||||
|
return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil
|
||||||
|
case "project":
|
||||||
|
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
||||||
|
case "project_named":
|
||||||
|
return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil
|
||||||
|
case "named":
|
||||||
|
return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil
|
||||||
|
default:
|
||||||
|
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAbsolute validates that the source path is under one of the allowed prefixes.
|
||||||
|
func resolveAbsolute(source, allowedPathsJSON string) (string, error) {
|
||||||
|
if source == "" {
|
||||||
|
return "", fmt.Errorf("absolute scope requires a source path")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := filepath.Clean(source)
|
||||||
|
if !filepath.IsAbs(cleaned) {
|
||||||
|
return "", fmt.Errorf("absolute scope requires an absolute source path (starting with /)")
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := parseAllowedPaths(allowedPathsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse allowed volume paths: %w", err)
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return "", fmt.Errorf("absolute volume paths are disabled (no allowed paths configured in settings)")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range allowed {
|
||||||
|
prefixClean := filepath.Clean(prefix)
|
||||||
|
if strings.HasPrefix(cleaned, prefixClean+string(filepath.Separator)) || cleaned == prefixClean {
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("path %q is not under any allowed volume path", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAllowedPaths parses a JSON array of path strings.
|
||||||
|
func parseAllowedPaths(jsonStr string) ([]string, error) {
|
||||||
|
if jsonStr == "" || jsonStr == "[]" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &paths); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAllowedPaths is the exported version for use in API validation.
|
||||||
|
func ParseAllowedPaths(jsonStr string) ([]string, error) {
|
||||||
|
return parseAllowedPaths(jsonStr)
|
||||||
|
}
|
||||||
+62
-1
@@ -21,7 +21,8 @@ import type {
|
|||||||
StandaloneProxy,
|
StandaloneProxy,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
Volume,
|
Volume,
|
||||||
VolumeScopeInfo
|
VolumeScopeInfo,
|
||||||
|
BrowseResult
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -352,6 +353,66 @@ export function deleteVolume(
|
|||||||
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
|
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function browseVolume(
|
||||||
|
projectId: string,
|
||||||
|
volId: string,
|
||||||
|
params?: { path?: string; stage?: string; tag?: string }
|
||||||
|
): Promise<BrowseResult> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.path) query.set('path', params.path);
|
||||||
|
if (params?.stage) query.set('stage', params.stage);
|
||||||
|
if (params?.tag) query.set('tag', params.tag);
|
||||||
|
const qs = query.toString();
|
||||||
|
return get<BrowseResult>(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function volumeDownloadUrl(
|
||||||
|
projectId: string,
|
||||||
|
volId: string,
|
||||||
|
params?: { path?: string; stage?: string; tag?: string }
|
||||||
|
): string {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.path) query.set('path', params.path);
|
||||||
|
if (params?.stage) query.set('stage', params.stage);
|
||||||
|
if (params?.tag) query.set('tag', params.tag);
|
||||||
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||||
|
if (token) query.set('token', token);
|
||||||
|
const qs = query.toString();
|
||||||
|
return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadToVolume(
|
||||||
|
projectId: string,
|
||||||
|
volId: string,
|
||||||
|
files: FileList,
|
||||||
|
params?: { path?: string; stage?: string; tag?: string }
|
||||||
|
): Promise<{ uploaded: string[]; count: number }> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.path) query.set('path', params.path);
|
||||||
|
if (params?.stage) query.set('stage', params.stage);
|
||||||
|
if (params?.tag) query.set('tag', params.tag);
|
||||||
|
const qs = query.toString();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await res.json();
|
||||||
|
if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed');
|
||||||
|
return envelope.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event Log ───────────────────────────────────────────────────────
|
// ── Event Log ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchEventLog(params?: {
|
export function fetchEventLog(params?: {
|
||||||
|
|||||||
@@ -144,6 +144,22 @@
|
|||||||
"updateFailed": "Failed to update volume",
|
"updateFailed": "Failed to update volume",
|
||||||
"deleteFailed": "Failed to delete volume"
|
"deleteFailed": "Failed to delete volume"
|
||||||
},
|
},
|
||||||
|
"volumeBrowser": {
|
||||||
|
"title": "Volume Browser",
|
||||||
|
"loadFailed": "Failed to load directory",
|
||||||
|
"empty": "This directory is empty.",
|
||||||
|
"name": "Name",
|
||||||
|
"size": "Size",
|
||||||
|
"modified": "Modified",
|
||||||
|
"downloadAll": "Download volume as ZIP",
|
||||||
|
"downloadFolder": "Download folder as ZIP",
|
||||||
|
"upload": "Upload files",
|
||||||
|
"uploaded": "Uploaded",
|
||||||
|
"files": "file(s)",
|
||||||
|
"uploadFailed": "Failed to upload files",
|
||||||
|
"browse": "Browse",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"quickDeploy": {
|
"quickDeploy": {
|
||||||
"title": "Quick Deploy",
|
"title": "Quick Deploy",
|
||||||
"description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.",
|
"description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.",
|
||||||
|
|||||||
@@ -144,6 +144,22 @@
|
|||||||
"updateFailed": "Не удалось обновить том",
|
"updateFailed": "Не удалось обновить том",
|
||||||
"deleteFailed": "Не удалось удалить том"
|
"deleteFailed": "Не удалось удалить том"
|
||||||
},
|
},
|
||||||
|
"volumeBrowser": {
|
||||||
|
"title": "Обзор тома",
|
||||||
|
"loadFailed": "Не удалось загрузить каталог",
|
||||||
|
"empty": "Этот каталог пуст.",
|
||||||
|
"name": "Имя",
|
||||||
|
"size": "Размер",
|
||||||
|
"modified": "Изменён",
|
||||||
|
"downloadAll": "Скачать том как ZIP",
|
||||||
|
"downloadFolder": "Скачать папку как ZIP",
|
||||||
|
"upload": "Загрузить файлы",
|
||||||
|
"uploaded": "Загружено",
|
||||||
|
"files": "файл(ов)",
|
||||||
|
"uploadFailed": "Не удалось загрузить файлы",
|
||||||
|
"browse": "Обзор",
|
||||||
|
"download": "Скачать"
|
||||||
|
},
|
||||||
"quickDeploy": {
|
"quickDeploy": {
|
||||||
"title": "Быстрый деплой",
|
"title": "Быстрый деплой",
|
||||||
"description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.",
|
"description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.",
|
||||||
|
|||||||
+16
-1
@@ -107,6 +107,7 @@ export interface Settings {
|
|||||||
base_volume_path: string;
|
base_volume_path: string;
|
||||||
ssl_certificate_id: number;
|
ssl_certificate_id: number;
|
||||||
stale_threshold_days: number;
|
stale_threshold_days: number;
|
||||||
|
allowed_volume_paths: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ export interface EntityPickerItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Volume scope determines the sharing level. */
|
/** Volume scope determines the sharing level. */
|
||||||
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral';
|
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral' | 'absolute';
|
||||||
|
|
||||||
/** Volume mount configuration for a project. */
|
/** Volume mount configuration for a project. */
|
||||||
export interface Volume {
|
export interface Volume {
|
||||||
@@ -185,6 +186,20 @@ export interface VolumeScopeInfo {
|
|||||||
path_example: string;
|
path_example: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A file or directory entry in a volume listing. */
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
size: number;
|
||||||
|
mod_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from the volume browse endpoint. */
|
||||||
|
export interface BrowseResult {
|
||||||
|
path: string;
|
||||||
|
entries: FileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Docker daemon health check result. */
|
/** Docker daemon health check result. */
|
||||||
export interface DockerHealth {
|
export interface DockerHealth {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX } from '$lib/components/icons';
|
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconExternalLink } from '$lib/components/icons';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
|
|
||||||
|
function downloadUrl(volId: string): string {
|
||||||
|
return api.volumeDownloadUrl(projectId, volId);
|
||||||
|
}
|
||||||
|
|
||||||
let volumes = $state<Volume[]>([]);
|
let volumes = $state<Volume[]>([]);
|
||||||
let scopeInfos = $state<VolumeScopeInfo[]>([]);
|
let scopeInfos = $state<VolumeScopeInfo[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -253,6 +257,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
{#if vol.scope !== 'ephemeral'}
|
||||||
|
<a href="/projects/{projectId}/volumes/{vol.id}/browse" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" title={$t('volumeBrowser.browse')}>
|
||||||
|
<IconSearch size={16} />
|
||||||
|
</a>
|
||||||
|
<a href={downloadUrl(vol.id)} target="_blank" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-emerald-50 hover:text-emerald-600 transition-colors" title={$t('volumeBrowser.download')}>
|
||||||
|
<IconExternalLink size={16} />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => handleDelete(vol.id)}><IconTrash size={16} /></button>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => handleDelete(vol.id)}><IconTrash size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import type { FileEntry } from '$lib/types';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { IconChevronRight, IconLoader } from '$lib/components/icons';
|
||||||
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
|
|
||||||
|
const projectId = $derived($page.params.id ?? '');
|
||||||
|
const volId = $derived($page.params.volId ?? '');
|
||||||
|
|
||||||
|
let entries = $state<FileEntry[]>([]);
|
||||||
|
let currentPath = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
// Query params for instance/stage scoped volumes.
|
||||||
|
const stage = $derived($page.url.searchParams.get('stage') ?? '');
|
||||||
|
const tag = $derived($page.url.searchParams.get('tag') ?? '');
|
||||||
|
|
||||||
|
const breadcrumbs = $derived(() => {
|
||||||
|
if (!currentPath) return [];
|
||||||
|
return currentPath.split('/').filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fileIcon(entry: FileEntry): string {
|
||||||
|
if (entry.is_dir) return '📁';
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||||||
|
txt: '📄', md: '📄', log: '📄', csv: '📄',
|
||||||
|
json: '📋', yaml: '📋', yml: '📋', toml: '📋', xml: '📋',
|
||||||
|
js: '📜', ts: '📜', go: '📜', py: '📜', rs: '📜', sh: '📜',
|
||||||
|
zip: '📦', tar: '📦', gz: '📦', rar: '📦',
|
||||||
|
db: '🗄️', sqlite: '🗄️', sql: '🗄️',
|
||||||
|
};
|
||||||
|
return icons[ext] ?? '📄';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDir(path: string) {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const result = await api.browseVolume(projectId, volId, { path, stage, tag });
|
||||||
|
entries = result.entries;
|
||||||
|
currentPath = result.path || '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
loadDir(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
const path = parts.slice(0, index + 1).join('/');
|
||||||
|
navigateTo(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntryClick(entry: FileEntry) {
|
||||||
|
if (entry.is_dir) {
|
||||||
|
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||||
|
navigateTo(newPath);
|
||||||
|
} else {
|
||||||
|
// Download single file.
|
||||||
|
const filePath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||||
|
window.open(api.volumeDownloadUrl(projectId, volId, { path: filePath, stage, tag }), '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCurrent() {
|
||||||
|
window.open(api.volumeDownloadUrl(projectId, volId, { path: currentPath, stage, tag }), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!fileInput.files?.length) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const result = await api.uploadToVolume(projectId, volId, fileInput.files, { path: currentPath, stage, tag });
|
||||||
|
toasts.success(`${$t('volumeBrowser.uploaded')} ${result.count} ${$t('volumeBrowser.files')}`);
|
||||||
|
fileInput.value = '';
|
||||||
|
await loadDir(currentPath);
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(e instanceof Error ? e.message : $t('volumeBrowser.uploadFailed'));
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void projectId;
|
||||||
|
void volId;
|
||||||
|
loadDir('');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('volumeBrowser.title')} - {$t('app.name')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||||
|
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
<a href="/projects/{projectId}/volumes" class="hover:text-[var(--text-link)] transition-colors">{$t('volumeEditor.title')}</a>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||||
|
onclick={downloadCurrent}
|
||||||
|
>
|
||||||
|
📦 {currentPath ? $t('volumeBrowser.downloadFolder') : $t('volumeBrowser.downloadAll')}
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors {uploading ? 'opacity-50 pointer-events-none' : ''}"
|
||||||
|
>
|
||||||
|
{#if uploading}
|
||||||
|
<IconLoader size={14} class="animate-spin" />
|
||||||
|
{/if}
|
||||||
|
{$t('volumeBrowser.upload')}
|
||||||
|
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<nav class="flex items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {currentPath === '' ? 'font-semibold' : ''}"
|
||||||
|
onclick={() => navigateTo('')}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
{#each breadcrumbs() as segment, i}
|
||||||
|
<IconChevronRight size={12} class="text-[var(--text-tertiary)]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {i === breadcrumbs().length - 1 ? 'font-semibold text-[var(--text-primary)]' : ''}"
|
||||||
|
onclick={() => navigateToBreadcrumb(i)}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton height="16rem" />
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||||
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||||
|
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={() => loadDir(currentPath)}>
|
||||||
|
{$t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if entries.length === 0}
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
||||||
|
<p class="text-sm text-[var(--text-tertiary)]">{$t('volumeBrowser.empty')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||||
|
<thead class="bg-[var(--surface-card-hover)]">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.name')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.size')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.modified')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||||
|
{#if currentPath}
|
||||||
|
<tr class="hover:bg-[var(--surface-card-hover)] cursor-pointer transition-colors" onclick={() => {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
navigateTo(parts.join('/'));
|
||||||
|
}}>
|
||||||
|
<td class="px-4 py-2 text-sm text-[var(--text-link)]">
|
||||||
|
<span class="mr-2">📁</span>..
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#each entries.sort((a, b) => {
|
||||||
|
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}) as entry (entry.name)}
|
||||||
|
<tr
|
||||||
|
class="hover:bg-[var(--surface-card-hover)] transition-colors {entry.is_dir ? 'cursor-pointer' : ''}"
|
||||||
|
onclick={() => handleEntryClick(entry)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<span class="mr-2">{fileIcon(entry)}</span>
|
||||||
|
{#if entry.is_dir}
|
||||||
|
<span class="text-[var(--text-link)]">{entry.name}</span>
|
||||||
|
{:else}
|
||||||
|
{entry.name}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||||
|
{entry.is_dir ? '—' : formatSize(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs text-[var(--text-tertiary)]">
|
||||||
|
{formatDate(entry.mod_time)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
Reference in New Issue
Block a user