Add nasctl: Go NAS control plane with React frontend

This commit is contained in:
2026-07-05 17:37:19 -04:00
parent 359fd5a160
commit 4f0754ecc5
56 changed files with 6725 additions and 1 deletions
+195
View File
@@ -0,0 +1,195 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/darroyo/nasctl/internal/db"
"github.com/darroyo/nasctl/internal/engine"
)
const (
sessionCookieName = "nasctl_session"
sessionTTL = 12 * time.Hour
signingKeySetting = "session_signing_key"
)
type AuthService struct {
db *db.DB
secret []byte
}
func NewAuthService(database *db.DB) (*AuthService, error) {
secret, err := loadOrCreateSecret(database)
if err != nil {
return nil, err
}
return &AuthService{db: database, secret: secret}, nil
}
func loadOrCreateSecret(database *db.DB) ([]byte, error) {
value, ok, err := database.GetSetting(signingKeySetting)
if err != nil {
return nil, err
}
if ok {
return hex.DecodeString(value)
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("generate signing key: %w", err)
}
if err := database.SetSetting(signingKeySetting, hex.EncodeToString(buf)); err != nil {
return nil, err
}
return buf, nil
}
// EnsureAdmin creates an initial admin if none exists.
func (a *AuthService) EnsureAdmin(username, password string) (bool, error) {
count, err := a.db.CountAdmins()
if err != nil {
return false, err
}
if count > 0 {
return false, nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return false, fmt.Errorf("hash password: %w", err)
}
if _, err := a.db.CreateAdmin(username, string(hash)); err != nil {
return false, err
}
return true, nil
}
func (a *AuthService) Authenticate(username, password string) bool {
admin, err := a.db.GetAdminByUsername(username)
if err != nil {
// Run a dummy hash comparison to reduce timing side-channels.
_ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password))
return false
}
return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil
}
func (a *AuthService) issueToken(username string) string {
expiry := time.Now().Add(sessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", username, expiry)
sig := a.sign(payload)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
}
func (a *AuthService) sign(payload string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *AuthService) verifyToken(token string) (string, bool) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
expected := a.sign(payload)
if subtle.ConstantTimeCompare([]byte(expected), []byte(parts[1])) != 1 {
return "", false
}
segs := strings.SplitN(payload, "|", 2)
if len(segs) != 2 {
return "", false
}
expiry, err := strconv.ParseInt(segs[1], 10, 64)
if err != nil || time.Now().Unix() > expiry {
return "", false
}
return segs[0], true
}
func (a *AuthService) setSessionCookie(w http.ResponseWriter, username string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: a.issueToken(username),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
MaxAge: int(sessionTTL.Seconds()),
})
}
func (a *AuthService) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func (a *AuthService) currentUser(r *http.Request) (string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return "", false
}
return a.verifyToken(cookie.Value)
}
// RequireAuth wraps API handlers that must be authenticated.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
next.ServeHTTP(w, r)
return
}
if _, ok := s.Auth.currentUser(r); !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r)
})
}
type Server struct {
DB *db.DB
Engine *engine.Engine
AllowedRoots []string
Auth *AuthService
}
type Options struct {
AllowedRoots []string
Auth *AuthService
}
func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
return &Server{
DB: database,
Engine: eng,
AllowedRoots: opts.AllowedRoots,
Auth: opts.Auth,
}
}
func (s *Server) Handler() http.Handler {
return NewRouter(s)
}
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist/*
var Dist embed.FS
+35
View File
@@ -0,0 +1,35 @@
package web
import (
"net/http"
)
func (s *Server) handleListDirty(w http.ResponseWriter, r *http.Request) {
modules, err := s.Engine.DirtyList(r.Context(), s.DB)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"modules": modules})
}
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
result, err := s.Engine.ApplyAll(r.Context(), s.DB)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
"results": result.Results,
})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) handleApplyLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.DB.ListApplyLog(100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
}
+49
View File
@@ -0,0 +1,49 @@
package web
import (
"encoding/json"
"net/http"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
writeError(w, http.StatusServiceUnavailable, "auth not configured")
return
}
defer r.Body.Close()
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !s.Auth.Authenticate(req.Username, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.Auth.setSessionCookie(w, req.Username)
writeJSON(w, http.StatusOK, map[string]any{"username": req.Username})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if s.Auth != nil {
s.Auth.clearSessionCookie(w)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "username": ""})
return
}
username, ok := s.Auth.currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": ok,
"username": username,
})
}
+148
View File
@@ -0,0 +1,148 @@
package web
import (
"io"
"net/http"
"strings"
"encoding/json"
"github.com/go-chi/chi/v5"
"github.com/darroyo/nasctl/internal/db"
"github.com/darroyo/nasctl/internal/modules/nfs"
"github.com/darroyo/nasctl/internal/system"
)
type nfsExportRequest struct {
Path string `json:"path"`
Clients []string `json:"clients"`
Options string `json:"options"`
}
func (req nfsExportRequest) validate(allowedRoots []string) error {
if err := system.ValidatePathAllowed(req.Path, allowedRoots); err != nil {
return err
}
for _, client := range req.Clients {
if err := system.ValidateNFSClient(client); err != nil {
return err
}
}
if strings.TrimSpace(req.Options) == "" {
return nil
}
return system.ValidateNFSOptions(req.Options)
}
func (req nfsExportRequest) toModel() db.NFSExport {
options := strings.TrimSpace(req.Options)
if options == "" {
options = "rw,sync,no_root_squash"
}
return db.NFSExport{
Path: req.Path,
Clients: req.Clients,
Options: options,
}
}
func (s *Server) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
exports, err := s.DB.ListNFSExports()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"exports": exports})
}
func (s *Server) handleGetNFSExport(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
export, err := s.DB.GetNFSExport(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, export)
}
func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
req, err := decodeNFSExportRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := req.validate(s.AllowedRoots); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
export, err := s.DB.CreateNFSExport(req.toModel())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.DB.MarkDirty(nfs.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, export)
}
func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
req, err := decodeNFSExportRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := req.validate(s.AllowedRoots); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
export, err := s.DB.UpdateNFSExport(id, req.toModel())
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err := s.DB.MarkDirty(nfs.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, export)
}
func (s *Server) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.DB.DeleteNFSExport(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err := s.DB.MarkDirty(nfs.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func decodeNFSExportRequest(body io.ReadCloser) (nfsExportRequest, error) {
defer body.Close()
var req nfsExportRequest
if err := json.NewDecoder(body).Decode(&req); err != nil {
return nfsExportRequest{}, err
}
return req, nil
}
+169
View File
@@ -0,0 +1,169 @@
package web
import (
"encoding/json"
"io"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/darroyo/nasctl/internal/db"
"github.com/darroyo/nasctl/internal/modules/samba"
"github.com/darroyo/nasctl/internal/system"
)
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
type sambaShareRequest struct {
Name string `json:"name"`
Path string `json:"path"`
Comment string `json:"comment"`
ReadOnly bool `json:"read_only"`
GuestOK bool `json:"guest_ok"`
ValidUsers []string `json:"valid_users"`
ValidGroups []string `json:"valid_groups"`
}
func (req sambaShareRequest) validate(allowedRoots []string) error {
if err := system.ValidateShareName(req.Name); err != nil {
return err
}
if err := system.ValidatePathAllowed(req.Path, allowedRoots); err != nil {
return err
}
for _, user := range req.ValidUsers {
if err := system.ValidateUsername(user); err != nil {
return err
}
}
return nil
}
func (req sambaShareRequest) toModel() db.SambaShare {
return db.SambaShare{
Name: req.Name,
Path: req.Path,
Comment: req.Comment,
ReadOnly: req.ReadOnly,
GuestOK: req.GuestOK,
ValidUsers: req.ValidUsers,
ValidGroups: req.ValidGroups,
}
}
func (s *Server) handleListSambaShares(w http.ResponseWriter, r *http.Request) {
shares, err := s.DB.ListSambaShares()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"shares": shares})
}
func (s *Server) handleGetSambaShare(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
share, err := s.DB.GetSambaShare(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, share)
}
func (s *Server) handleCreateSambaShare(w http.ResponseWriter, r *http.Request) {
req, err := decodeSambaShareRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := req.validate(s.AllowedRoots); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
share, err := s.DB.CreateSambaShare(req.toModel())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.DB.MarkDirty(samba.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleUpdateSambaShare(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
req, err := decodeSambaShareRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := req.validate(s.AllowedRoots); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
share, err := s.DB.UpdateSambaShare(id, req.toModel())
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err := s.DB.MarkDirty(samba.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, share)
}
func (s *Server) handleDeleteSambaShare(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.DB.DeleteSambaShare(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err := s.DB.MarkDirty(samba.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func decodeSambaShareRequest(body io.ReadCloser) (sambaShareRequest, error) {
defer body.Close()
var req sambaShareRequest
if err := json.NewDecoder(body).Decode(&req); err != nil {
return sambaShareRequest{}, err
}
return req, nil
}
func parseID(raw string) (int64, error) {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, err
}
return id, nil
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"net/http"
"strings"
"syscall"
"github.com/darroyo/nasctl/internal/system"
)
type diskUsage struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedBytes uint64 `json:"used_bytes"`
UsedPercent float64 `json:"used_percent"`
}
type serviceStatus struct {
Name string `json:"name"`
Active bool `json:"active"`
State string `json:"state"`
}
func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"disks": collectDiskUsage(),
"services": collectServiceStatus(r),
}
writeJSON(w, http.StatusOK, status)
}
func collectDiskUsage() []diskUsage {
paths := []string{"/"}
var usages []diskUsage
for _, path := range paths {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
continue
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
used := total - free
var pct float64
if total > 0 {
pct = float64(used) / float64(total) * 100
}
usages = append(usages, diskUsage{
Path: path,
TotalBytes: total,
FreeBytes: free,
UsedBytes: used,
UsedPercent: pct,
})
}
return usages
}
func collectServiceStatus(r *http.Request) []serviceStatus {
services := []string{"smbd", "nfs-server"}
var statuses []serviceStatus
for _, name := range services {
stdout, _, err := system.Run(r.Context(), "systemctl", "is-active", name)
state := strings.TrimSpace(stdout)
if state == "" && err != nil {
state = "unknown"
}
statuses = append(statuses, serviceStatus{
Name: name,
Active: state == "active",
State: state,
})
}
return statuses
}
+156
View File
@@ -0,0 +1,156 @@
package web
import (
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/darroyo/nasctl/internal/db"
"github.com/darroyo/nasctl/internal/modules/users"
"github.com/darroyo/nasctl/internal/system"
)
type userRequest struct {
Username string `json:"username"`
Groups []string `json:"groups"`
SMBEnabled bool `json:"smb_enabled"`
Disabled bool `json:"disabled"`
// Password is write-only; it is never returned or logged.
Password string `json:"password"`
}
func (req userRequest) validate() error {
if err := system.ValidateUsername(req.Username); err != nil {
return err
}
for _, group := range req.Groups {
if err := system.ValidateUsername(group); err != nil {
return err
}
}
return nil
}
func (req userRequest) toModel() db.User {
return db.User{
Username: req.Username,
Groups: req.Groups,
SMBEnabled: req.SMBEnabled,
Disabled: req.Disabled,
PendingPassword: req.Password,
}
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": list})
}
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
user, err := s.DB.GetUser(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
req, err := decodeUserRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := req.validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.DB.CreateUser(req.toModel())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
req, err := decodeUserRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := req.validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
existing, err := s.DB.GetUser(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
model := req.toModel()
// Preserve a previously stored pending password if none is supplied now.
if model.PendingPassword == "" {
model.PendingPassword = existing.PendingPassword
}
user, err := s.DB.UpdateUser(id, model)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.DB.DeleteUser(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func decodeUserRequest(body io.ReadCloser) (userRequest, error) {
defer body.Close()
var req userRequest
if err := json.NewDecoder(body).Decode(&req); err != nil {
return userRequest{}, err
}
return req, nil
}
+105
View File
@@ -0,0 +1,105 @@
package web
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func NewRouter(s *Server) chi.Router {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api", func(api chi.Router) {
// Public auth endpoints.
api.Post("/auth/login", s.handleLogin)
api.Post("/auth/logout", s.handleLogout)
api.Get("/auth/status", s.handleAuthStatus)
// Everything below requires authentication.
api.Group(func(protected chi.Router) {
protected.Use(s.RequireAuth)
protected.Get("/dirty", s.handleListDirty)
protected.Post("/apply", s.handleApply)
protected.Get("/apply/log", s.handleApplyLog)
protected.Get("/system/status", s.handleSystemStatus)
protected.Route("/samba/shares", func(shares chi.Router) {
shares.Get("/", s.handleListSambaShares)
shares.Post("/", s.handleCreateSambaShare)
shares.Route("/{id}", func(item chi.Router) {
item.Get("/", s.handleGetSambaShare)
item.Put("/", s.handleUpdateSambaShare)
item.Delete("/", s.handleDeleteSambaShare)
})
})
protected.Route("/nfs/exports", func(exports chi.Router) {
exports.Get("/", s.handleListNFSExports)
exports.Post("/", s.handleCreateNFSExport)
exports.Route("/{id}", func(item chi.Router) {
item.Get("/", s.handleGetNFSExport)
item.Put("/", s.handleUpdateNFSExport)
item.Delete("/", s.handleDeleteNFSExport)
})
})
protected.Route("/users", func(users chi.Router) {
users.Get("/", s.handleListUsers)
users.Post("/", s.handleCreateUser)
users.Route("/{id}", func(item chi.Router) {
item.Get("/", s.handleGetUser)
item.Put("/", s.handleUpdateUser)
item.Delete("/", s.handleDeleteUser)
})
})
})
})
r.NotFound(s.handleStatic)
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
})
return r
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := Dist.ReadFile("dist/" + path)
if err != nil {
if path != "index.html" {
data, err = Dist.ReadFile("dist/index.html")
}
if err != nil {
http.NotFound(w, r)
return
}
}
if strings.HasSuffix(path, ".html") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
} else if strings.HasSuffix(path, ".js") {
w.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(path, ".css") {
w.Header().Set("Content-Type", "text/css")
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}