Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"context"
"errors"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/api"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
if err := api.ValidateAuthConfig(); err != nil {
return err
}
root, err := filepath.Abs(getenv("CONTAINER_DATA_ROOT", getenv("DATA_ROOT", "./data")))
if err != nil {
return err
}
if err = os.MkdirAll(root, 0700); err != nil {
return err
}
if os.Getenv("HOST_DATA_ROOT") == "" {
os.Setenv("HOST_DATA_ROOT", root)
}
s, err := store.Open(filepath.Join(root, "db.sqlite"))
if err != nil {
return err
}
defer s.Close()
var count int
if err = s.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
return err
}
if count == 0 {
password := os.Getenv("ADMIN_PASSWORD")
if len(password) < 12 || len(password) > 72 {
return fmt.Errorf("first startup requires ADMIN_PASSWORD of 12–72 bytes")
}
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if e != nil {
return e
}
if err = s.CreateUser(store.User{ID: store.ID(), Username: getenv("ADMIN_USERNAME", "admin"), Admin: true, Quota: api.DefaultQuota()}, string(hash)); err != nil {
return err
}
}
panel := api.New(s, root)
if err := panel.ConfigError(); err != nil {
return err
}
checkCtx, checkCancel := context.WithTimeout(context.Background(), 20*time.Second)
if err := panel.Reconcile(checkCtx); err != nil {
log.Printf("startup reconciliation unavailable; Docker connectivity must be checked: %v", err)
}
checkCancel()
if err := panel.StartImageBuilds(); err != nil {
return fmt.Errorf("image build recovery: %w", err)
}
backend := panel.Handler()
static := http.FileServer(http.Dir(getenv("WEB_DIR", "web/dist")))
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/dsh/") {
backend.ServeHTTP(w, r)
return
}
if r.URL.Path == "/healthz" {
w.Write([]byte("ok"))
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", 405)
return
}
// The embedded runtime visits the same-origin SPA renewal bridge.
// Keep cross-origin framing blocked without blocking that bridge.
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
static.ServeHTTP(w, r)
})
srv := &http.Server{Addr: getenv("LISTEN_ADDR", "127.0.0.1:8088"), Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := panel.StartIntents(ctx); err != nil {
return fmt.Errorf("creation intent recovery: %w", err)
}
go panel.RunQuotaMonitor(ctx)
return serve(ctx, srv)
}
func serve(ctx context.Context, srv *http.Server) error {
drained := make(chan struct{})
finished := make(chan struct{})
defer close(finished)
go func() {
defer close(drained)
select {
case <-ctx.Done():
case <-finished:
return
}
c, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(c); err != nil {
log.Printf("graceful shutdown deadline reached: %v", err)
_ = srv.Close()
}
}()
log.Printf("dsh panel listening on %s", srv.Addr)
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
<-drained
return nil
}
return err
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}