791cd4d6af
Build / build (push) Successful in 12m20s
Rebrand the project as Tinyforge to reflect its evolution from a Docker container watcher into a self-hosted mini CI/deployment platform. Rename covers: Go module path, Docker labels, DB/config filenames, JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend i18n, README with static sites docs, and all code comments.
210 lines
6.1 KiB
Go
210 lines
6.1 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
"github.com/alexei/tinyforge/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),
|
|
})
|
|
}
|