679 lines
19 KiB
Go
679 lines
19 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/example/glpi-env-controller/internal/backup"
|
|
"github.com/example/glpi-env-controller/internal/dockerctl"
|
|
"github.com/example/glpi-env-controller/internal/envfile"
|
|
)
|
|
|
|
const unchangedSecret = "__ENV_CONTROLLER_UNCHANGED__"
|
|
|
|
type Server struct {
|
|
cfg Config
|
|
password string
|
|
csrf string
|
|
tpl *template.Template
|
|
docker dockerctl.Controller
|
|
projects map[string]ProjectConfig
|
|
locks map[string]*sync.Mutex
|
|
logger *slog.Logger
|
|
}
|
|
|
|
type pageData struct {
|
|
Title string
|
|
CSRF string
|
|
Projects []projectSummary
|
|
Project *projectView
|
|
Flash string
|
|
FlashKind string
|
|
Now time.Time
|
|
}
|
|
|
|
type projectSummary struct {
|
|
ID, Title string
|
|
Missing, Extra, Backups int
|
|
Error string
|
|
}
|
|
|
|
type fieldView struct {
|
|
Key, Value, Description string
|
|
Secret, Missing, Extra, Duplicate, Long, Boolean bool
|
|
}
|
|
|
|
type projectView struct {
|
|
Config ProjectConfig
|
|
Fields []fieldView
|
|
Missing []string
|
|
Extra []string
|
|
Duplicates []string
|
|
Backups []backup.Entry
|
|
Targets []targetView
|
|
CurrentSHA256 string
|
|
}
|
|
|
|
type targetView struct {
|
|
Target dockerctl.Target
|
|
Status dockerctl.Status
|
|
}
|
|
|
|
func NewServer(cfg Config, password string, htmlTemplate string, logger *slog.Logger) (*Server, error) {
|
|
funcs := template.FuncMap{
|
|
"humanBytes": humanBytes,
|
|
"duration": func(d time.Duration) string { return d.Round(time.Millisecond).String() },
|
|
"join": strings.Join,
|
|
"hasAction": func(actions []string, action string) bool {
|
|
for _, a := range actions {
|
|
if a == action {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
}
|
|
tpl, err := template.New("page").Funcs(funcs).Parse(htmlTemplate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
csrfBytes := make([]byte, 32)
|
|
if _, err := rand.Read(csrfBytes); err != nil {
|
|
return nil, err
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
timeout, _ := time.ParseDuration(cfg.DockerTimeout)
|
|
s := &Server{cfg: cfg, password: password, csrf: base64.RawURLEncoding.EncodeToString(csrfBytes), tpl: tpl, docker: dockerctl.Controller{Timeout: timeout}, projects: map[string]ProjectConfig{}, locks: map[string]*sync.Mutex{}, logger: logger}
|
|
for _, p := range cfg.Projects {
|
|
s.projects[p.ID] = p
|
|
s.locks[p.ID] = &sync.Mutex{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", s.health)
|
|
mux.HandleFunc("/", s.route)
|
|
return s.securityHeaders(s.basicAuth(mux))
|
|
}
|
|
|
|
func (s *Server) AutoImport(ctx context.Context) {
|
|
if !s.cfg.AutoImportMissing {
|
|
return
|
|
}
|
|
for _, project := range s.cfg.Projects {
|
|
if _, err := s.importMissing(ctx, project, "startup", true, nil); err != nil {
|
|
s.logger.Error("automatic env import failed", "project", project.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.index(w, r)
|
|
return
|
|
}
|
|
parts := splitPath(r.URL.Path)
|
|
if len(parts) < 2 || parts[0] != "project" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
project, ok := s.projects[parts[1]]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if len(parts) == 2 {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.projectPage(w, r, project, "", "")
|
|
return
|
|
}
|
|
action := parts[2]
|
|
switch action {
|
|
case "save":
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.save(w, r, project)
|
|
case "import":
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.importHandler(w, r, project)
|
|
case "restore":
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.restore(w, r, project)
|
|
case "containers":
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.containers(w, r, project)
|
|
case "reveal":
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.reveal(w, r, project)
|
|
case "backup":
|
|
if r.Method != http.MethodGet || len(parts) != 4 {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
s.downloadBackup(w, r, project, parts[3])
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"status":"ok"}`)
|
|
}
|
|
|
|
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
|
data := pageData{Title: "ENV Controller", CSRF: s.csrf, Now: time.Now()}
|
|
for _, project := range s.cfg.Projects {
|
|
summary := projectSummary{ID: project.ID, Title: project.Title}
|
|
current, example, err := readDocs(project)
|
|
if err != nil {
|
|
summary.Error = err.Error()
|
|
} else {
|
|
missing, extra := envfile.Compare(current, example)
|
|
summary.Missing, summary.Extra = len(missing), len(extra)
|
|
}
|
|
entries, _ := (backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}).List()
|
|
summary.Backups = len(entries)
|
|
data.Projects = append(data.Projects, summary)
|
|
}
|
|
s.render(w, data)
|
|
}
|
|
|
|
func (s *Server) projectPage(w http.ResponseWriter, r *http.Request, project ProjectConfig, flash, kind string) {
|
|
view, err := s.loadProjectView(r.Context(), project)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.render(w, pageData{Title: project.Title, CSRF: s.csrf, Project: &view, Flash: flash, FlashKind: kind, Now: time.Now()})
|
|
}
|
|
|
|
func (s *Server) loadProjectView(ctx context.Context, project ProjectConfig) (projectView, error) {
|
|
current, example, err := readDocs(project)
|
|
if err != nil {
|
|
return projectView{}, err
|
|
}
|
|
curValues := current.Effective()
|
|
exValues := example.Effective()
|
|
descriptions := envfile.Descriptions(example)
|
|
occ := current.Occurrences()
|
|
missing, extra := envfile.Compare(current, example)
|
|
missingSet := setOf(missing)
|
|
extraSet := setOf(extra)
|
|
var keys []string
|
|
seen := map[string]struct{}{}
|
|
for _, line := range example.Lines {
|
|
if line.Kind == envfile.LineAssignment {
|
|
if _, ok := seen[line.Key]; !ok {
|
|
keys = append(keys, line.Key)
|
|
seen[line.Key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
var extraSorted []string
|
|
for key := range curValues {
|
|
if _, ok := seen[key]; !ok {
|
|
extraSorted = append(extraSorted, key)
|
|
}
|
|
}
|
|
sort.Strings(extraSorted)
|
|
keys = append(keys, extraSorted...)
|
|
var fields []fieldView
|
|
for _, key := range keys {
|
|
value, exists := curValues[key]
|
|
if !exists {
|
|
value = exValues[key]
|
|
}
|
|
secret := isSecret(key)
|
|
shown := value
|
|
if secret {
|
|
shown = unchangedSecret
|
|
}
|
|
fields = append(fields, fieldView{Key: key, Value: shown, Description: descriptions[key], Secret: secret, Missing: contains(missingSet, key), Extra: contains(extraSet, key), Duplicate: occ[key] > 1, Long: isLong(key, value), Boolean: isBoolean(exValues[key])})
|
|
}
|
|
var duplicates []string
|
|
for key, count := range occ {
|
|
if count > 1 {
|
|
duplicates = append(duplicates, fmt.Sprintf("%s (%dx)", key, count))
|
|
}
|
|
}
|
|
sort.Strings(duplicates)
|
|
entries, err := (backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}).List()
|
|
if err != nil {
|
|
return projectView{}, err
|
|
}
|
|
var targets []targetView
|
|
for _, target := range dockerctl.SortedTargets(project.Targets) {
|
|
targets = append(targets, targetView{Target: target, Status: s.docker.Status(ctx, target)})
|
|
}
|
|
return projectView{Config: project, Fields: fields, Missing: missing, Extra: extra, Duplicates: duplicates, Backups: entries, Targets: targets}, nil
|
|
}
|
|
|
|
func (s *Server) save(w http.ResponseWriter, r *http.Request, project ProjectConfig) {
|
|
if !s.validatePost(w, r) {
|
|
return
|
|
}
|
|
lock := s.locks[project.ID]
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
currentData, err := os.ReadFile(project.EnvFile)
|
|
if err != nil {
|
|
s.projectPage(w, r, project, err.Error(), "error")
|
|
return
|
|
}
|
|
current, err := envfile.Parse(currentData)
|
|
if err != nil {
|
|
s.projectPage(w, r, project, err.Error(), "error")
|
|
return
|
|
}
|
|
values := current.Effective()
|
|
for key := range values {
|
|
formKey := "v." + key
|
|
posted, ok := r.Form[formKey]
|
|
if !ok || len(posted) == 0 {
|
|
continue
|
|
}
|
|
value := posted[0]
|
|
if isSecret(key) && value == unchangedSecret {
|
|
continue
|
|
}
|
|
if err := current.Set(key, value); err != nil {
|
|
s.projectPage(w, r, project, err.Error(), "error")
|
|
return
|
|
}
|
|
}
|
|
store := backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}
|
|
entry, err := store.Create(project.EnvFile, "edit", s.cfg.Username)
|
|
if err != nil {
|
|
s.projectPage(w, r, project, "Backup fehlgeschlagen: "+err.Error(), "error")
|
|
return
|
|
}
|
|
if err := atomicWrite(project.EnvFile, current.Render()); err != nil {
|
|
s.projectPage(w, r, project, "Schreiben fehlgeschlagen: "+err.Error(), "error")
|
|
return
|
|
}
|
|
message := "Konfiguration gespeichert; Sicherung " + entry.Name + " wurde vorher erstellt."
|
|
message += s.applyFromForm(r.Context(), r, project)
|
|
s.projectPage(w, r, project, message, "success")
|
|
}
|
|
|
|
func (s *Server) importHandler(w http.ResponseWriter, r *http.Request, project ProjectConfig) {
|
|
if !s.validatePost(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
missing, err := s.importMissing(r.Context(), project, s.cfg.Username, true, r)
|
|
if err != nil {
|
|
s.projectPage(w, r, project, err.Error(), "error")
|
|
return
|
|
}
|
|
if len(missing) == 0 {
|
|
s.projectPage(w, r, project, "Keine neuen Einträge in .env.example gefunden.", "info")
|
|
return
|
|
}
|
|
message := fmt.Sprintf("%d neue Einträge importiert: %s.", len(missing), strings.Join(missing, ", "))
|
|
message += s.applyFromForm(r.Context(), r, project)
|
|
s.projectPage(w, r, project, message, "success")
|
|
}
|
|
|
|
func (s *Server) importMissing(_ context.Context, project ProjectConfig, actor string, withBackup bool, _ *http.Request) ([]string, error) {
|
|
lock := s.locks[project.ID]
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
current, example, err := readDocs(project)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
missing, _ := envfile.Compare(current, example)
|
|
if len(missing) == 0 {
|
|
return nil, nil
|
|
}
|
|
if withBackup {
|
|
if _, err := (backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}).Create(project.EnvFile, "import", actor); err != nil {
|
|
return nil, fmt.Errorf("backup before import: %w", err)
|
|
}
|
|
}
|
|
marker := "# --- Automatisch aus .env.example importiert am " + time.Now().Format(time.RFC3339) + " ---"
|
|
added, err := current.ImportMissing(example, marker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := atomicWrite(project.EnvFile, current.Render()); err != nil {
|
|
return nil, err
|
|
}
|
|
return added, nil
|
|
}
|
|
|
|
func (s *Server) restore(w http.ResponseWriter, r *http.Request, project ProjectConfig) {
|
|
if !s.validatePost(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
name := r.FormValue("backup")
|
|
if name == "" {
|
|
s.projectPage(w, r, project, "Keine Sicherung ausgewählt.", "error")
|
|
return
|
|
}
|
|
lock := s.locks[project.ID]
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
store := backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}
|
|
pre, err := store.Create(project.EnvFile, "pre_restore", s.cfg.Username)
|
|
if err != nil {
|
|
s.projectPage(w, r, project, "Sicherung vor Wiederherstellung fehlgeschlagen: "+err.Error(), "error")
|
|
return
|
|
}
|
|
data, err := store.Read(name)
|
|
if err != nil {
|
|
s.projectPage(w, r, project, err.Error(), "error")
|
|
return
|
|
}
|
|
if _, err := envfile.Parse(data); err != nil {
|
|
s.projectPage(w, r, project, "Ungültige Sicherung: "+err.Error(), "error")
|
|
return
|
|
}
|
|
if err := atomicWrite(project.EnvFile, data); err != nil {
|
|
s.projectPage(w, r, project, err.Error(), "error")
|
|
return
|
|
}
|
|
message := "Sicherung " + name + " wiederhergestellt. Der vorherige Stand wurde als " + pre.Name + " gesichert."
|
|
message += s.applyFromForm(r.Context(), r, project)
|
|
s.projectPage(w, r, project, message, "success")
|
|
}
|
|
|
|
func (s *Server) containers(w http.ResponseWriter, r *http.Request, project ProjectConfig) {
|
|
if !s.validatePost(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
message := s.applyFromForm(r.Context(), r, project)
|
|
if message == "" {
|
|
message = " Keine Container ausgewählt."
|
|
}
|
|
s.projectPage(w, r, project, strings.TrimSpace(message), "info")
|
|
}
|
|
|
|
func (s *Server) applyFromForm(ctx context.Context, r *http.Request, project ProjectConfig) string {
|
|
if r.Form == nil {
|
|
_ = r.ParseForm()
|
|
}
|
|
selected := r.Form["target"]
|
|
if len(selected) == 0 {
|
|
return ""
|
|
}
|
|
allow := map[string]dockerctl.Target{}
|
|
for _, target := range project.Targets {
|
|
allow[target.ContainerName] = target
|
|
}
|
|
var results []string
|
|
for _, name := range selected {
|
|
target, ok := allow[name]
|
|
if !ok {
|
|
results = append(results, name+": nicht freigegeben")
|
|
continue
|
|
}
|
|
action := r.FormValue("action." + name)
|
|
if action == "" {
|
|
action = target.DefaultAction
|
|
}
|
|
result := s.docker.Execute(ctx, target, action)
|
|
if result.Success {
|
|
results = append(results, fmt.Sprintf("%s: %s erfolgreich", name, action))
|
|
} else {
|
|
results = append(results, fmt.Sprintf("%s: %s fehlgeschlagen (%s)", name, action, result.Error))
|
|
}
|
|
}
|
|
return " Container-Aktionen: " + strings.Join(results, "; ") + "."
|
|
}
|
|
|
|
func (s *Server) reveal(w http.ResponseWriter, r *http.Request, project ProjectConfig) {
|
|
if !s.validatePost(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
key := r.FormValue("key")
|
|
if !isSecret(key) {
|
|
http.Error(w, "not a secret field", http.StatusBadRequest)
|
|
return
|
|
}
|
|
current, _, err := readDocs(project)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
value, ok := current.Effective()[key]
|
|
if !ok {
|
|
http.Error(w, "unknown key", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"value": value})
|
|
}
|
|
|
|
func (s *Server) downloadBackup(w http.ResponseWriter, r *http.Request, project ProjectConfig, name string) {
|
|
path, err := (backup.Store{Dir: project.BackupDir}).Path(name)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.ServeFile(w, r, path)
|
|
}
|
|
|
|
func (s *Server) validatePost(w http.ResponseWriter, r *http.Request) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return false
|
|
}
|
|
provided := r.FormValue("csrf")
|
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrf)) != 1 {
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, data pageData) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if err := s.tpl.ExecuteTemplate(w, "page", data); err != nil {
|
|
s.logger.Error("render page", "error", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) basicAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/healthz" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(s.cfg.Username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(s.password)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="ENV Controller", charset="UTF-8"`)
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func readDocs(project ProjectConfig) (*envfile.Document, *envfile.Document, error) {
|
|
curData, err := os.ReadFile(project.EnvFile)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read .env: %w", err)
|
|
}
|
|
exData, err := os.ReadFile(project.ExampleFile)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read .env.example: %w", err)
|
|
}
|
|
cur, err := envfile.Parse(curData)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
ex, err := envfile.Parse(exData)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return cur, ex, nil
|
|
}
|
|
|
|
func atomicWrite(path string, data []byte) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(path)
|
|
tmp, err := os.CreateTemp(dir, ".env-controller-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := preserveOwnership(tmpName, info); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return err
|
|
}
|
|
if d, err := os.Open(dir); err == nil {
|
|
_ = d.Sync()
|
|
_ = d.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func splitPath(path string) []string {
|
|
var out []string
|
|
for _, p := range strings.Split(strings.Trim(path, "/"), "/") {
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func methodNotAllowed(w http.ResponseWriter) {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
func setOf(values []string) map[string]struct{} {
|
|
m := map[string]struct{}{}
|
|
for _, v := range values {
|
|
m[v] = struct{}{}
|
|
}
|
|
return m
|
|
}
|
|
func contains(m map[string]struct{}, k string) bool { _, ok := m[k]; return ok }
|
|
func isSecret(key string) bool {
|
|
u := strings.ToUpper(key)
|
|
for _, part := range []string{"PASSWORD", "PASSWD", "SECRET", "TOKEN", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"} {
|
|
if strings.Contains(u, part) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func isLong(key, value string) bool {
|
|
return strings.Contains(value, "\n") || len(value) > 100 || strings.HasSuffix(strings.ToUpper(key), "_TEXT")
|
|
}
|
|
func isBoolean(value string) bool {
|
|
return strings.EqualFold(value, "true") || strings.EqualFold(value, "false")
|
|
}
|
|
func humanBytes(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return strconv.FormatInt(n, 10) + " B"
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n >= div*unit && exp < 4 {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|