feat: initialise project structure with core encoding queue, database models, and web management API
Build / Build and Push (push) Successful in 50s
Build / Build and Push (push) Successful in 50s
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"goencode/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := db.GetPendingJobs()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jobs)
|
||||
}
|
||||
|
||||
func (s *Server) handleBumpJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/jobs/bump/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.BumpJobPriority(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.qm.NotifySSE("queue_updated", nil)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/jobs/cancel/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteJob(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.qm.NotifySSE("queue_updated", nil)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetWatchFolders(w http.ResponseWriter, r *http.Request) {
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(folders)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var f db.WatchFolder
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.Enabled = true
|
||||
if err := db.AddWatchFolder(f); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.wm.Reload()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/folders/delete/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteWatchFolder(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.wm.Reload()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"goencode/internal/config"
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/logger"
|
||||
"goencode/internal/queue"
|
||||
"goencode/internal/watcher"
|
||||
rootweb "goencode/web"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
qm *queue.Manager
|
||||
wm *watcher.Manager
|
||||
sse *SSEServer
|
||||
mux *http.ServeMux
|
||||
sessionToken string
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, qm *queue.Manager, wm *watcher.Manager, sse *SSEServer) *Server {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
qm: qm,
|
||||
wm: wm,
|
||||
sse: sse,
|
||||
mux: http.NewServeMux(),
|
||||
sessionToken: token,
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.ListenAddr, s.cfg.Server.Port)
|
||||
|
||||
var handler http.Handler = s.mux
|
||||
if s.cfg.Auth.Username != "" {
|
||||
handler = s.authMiddleware(s.mux)
|
||||
}
|
||||
|
||||
return http.ListenAndServe(addr, handler)
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
importStrings := true // to ensure "strings" is imported if not already, wait, I need to check if strings is imported.
|
||||
_ = importStrings
|
||||
|
||||
// We'll use a simple slice of prefixes to bypass auth, but since there's only one,
|
||||
// let's just use string slicing or import "strings"
|
||||
|
||||
// To be safe without adding imports manually if I don't know the exact list:
|
||||
if s.cfg.Auth.Username == "" || r.URL.Path == "/login" || (len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie("goencode_session")
|
||||
if err != nil || cookie.Value != s.sessionToken {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
tmpl, err := template.ParseFS(rootweb.FS, "templates/login.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.Execute(w, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
|
||||
if user == s.cfg.Auth.Username && pass == s.cfg.Auth.Password {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "goencode_session",
|
||||
Value: s.sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, _ := template.ParseFS(rootweb.FS, "templates/login.html")
|
||||
tmpl.Execute(w, struct{ Error string }{"Invalid credentials"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "goencode_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/login", s.handleLogin)
|
||||
s.mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
||||
s.mux.HandleFunc("/api/sse", s.sse.HandleSSE)
|
||||
|
||||
s.mux.HandleFunc("/api/queue", s.handleGetQueue)
|
||||
s.mux.HandleFunc("/api/jobs/bump/", s.handleBumpJob)
|
||||
s.mux.HandleFunc("/api/jobs/cancel/", s.handleCancelJob)
|
||||
|
||||
s.mux.HandleFunc("/api/folders", s.handleGetWatchFolders)
|
||||
s.mux.HandleFunc("/api/folders/add", s.handleAddWatchFolder)
|
||||
s.mux.HandleFunc("/api/folders/delete/", s.handleDeleteWatchFolder)
|
||||
s.mux.HandleFunc("/api/logs", s.handleGetLogs)
|
||||
|
||||
// Static files
|
||||
s.mux.Handle("/static/", http.FileServer(http.FS(rootweb.FS)))
|
||||
|
||||
// Pages
|
||||
s.mux.HandleFunc("/", s.handleDashboard)
|
||||
s.mux.HandleFunc("/folders", s.handlePage("folders.html"))
|
||||
s.mux.HandleFunc("/history", s.handleHistory)
|
||||
s.mux.HandleFunc("/logs", s.handlePage("logs.html"))
|
||||
s.mux.HandleFunc("/settings", s.handlePage("settings.html"))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
logs := logger.GetRecentLogs()
|
||||
json.NewEncoder(w).Encode(logs)
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit && bytes > -unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
n := bytes
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
for n >= unit*unit && exp < len("KMGTPE")-1 {
|
||||
div *= unit
|
||||
exp++
|
||||
n /= unit
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
stats, _ := db.GetDashboardStats()
|
||||
|
||||
// Create formatted stats
|
||||
data := struct {
|
||||
AuthEnabled bool
|
||||
FilesEncoded int
|
||||
QueueLength int
|
||||
SavedSpace string
|
||||
}{
|
||||
AuthEnabled: s.cfg.Auth.Username != "",
|
||||
FilesEncoded: stats.FilesEncoded,
|
||||
QueueLength: stats.QueueLength,
|
||||
SavedSpace: formatBytes(stats.TotalSavedSpace),
|
||||
}
|
||||
|
||||
tmpl, err := template.New("layout").Funcs(template.FuncMap{
|
||||
"formatBytes": formatBytes,
|
||||
}).ParseFS(rootweb.FS, "templates/layout.html", "templates/dashboard.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) handlePage(tmplName string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFS(rootweb.FS, "templates/layout.html", filepath.Join("templates", tmplName))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := struct{ AuthEnabled bool }{AuthEnabled: s.cfg.Auth.Username != ""}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("Template execution error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
reports, total, err := db.GetJobReports(50, 0) // Basic pagination for now
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
AuthEnabled bool
|
||||
Reports []db.JobReport
|
||||
Total int
|
||||
}{
|
||||
AuthEnabled: s.cfg.Auth.Username != "",
|
||||
Reports: reports,
|
||||
Total: total,
|
||||
}
|
||||
|
||||
tmpl, err := template.New("layout").Funcs(template.FuncMap{
|
||||
"formatBytes": formatBytes,
|
||||
}).ParseFS(rootweb.FS, "templates/layout.html", "templates/history.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("Template execution error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SSEMessage struct {
|
||||
Event string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
type SSEServer struct {
|
||||
clients map[chan SSEMessage]bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSSEServer() *SSEServer {
|
||||
return &SSEServer{
|
||||
clients: make(map[chan SSEMessage]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSEServer) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
clientChan := make(chan SSEMessage)
|
||||
s.mu.Lock()
|
||||
s.clients[clientChan] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, clientChan)
|
||||
s.mu.Unlock()
|
||||
close(clientChan)
|
||||
}()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg := <-clientChan:
|
||||
dataBytes, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", msg.Event, string(dataBytes))
|
||||
w.(http.Flusher).Flush()
|
||||
case <-time.After(30 * time.Second):
|
||||
// Keepalive
|
||||
fmt.Fprintf(w, "event: ping\ndata: {}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSEServer) Broadcast(event string, data interface{}) {
|
||||
msg := SSEMessage{Event: event, Data: data}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for clientChan := range s.clients {
|
||||
select {
|
||||
case clientChan <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user