Initial DDC release

This commit is contained in:
SirBlob committed 2026-10-25 20:24:50 -04:00
commit 434f6441da
57 files changed
+4606

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
package server
import (
"net"
"net/http"
"strings"
"ddc/internal/auth"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type sessionResponse struct {
Authenticated bool `json:"authenticated"`
}
func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) {
client := server.clientAddress(request)
if !server.limiter.Allowed(client) {
writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later")
return
}
var body loginRequest
if err := decodeJSON(request, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid request body")
return
}
if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) {
server.limiter.RecordFailure(client)
writeError(writer, http.StatusUnauthorized, "invalid username or password")
return
}
server.limiter.Reset(client)
token, err := server.sessions.Create()
if err != nil {
writeError(writer, http.StatusInternalServerError, "could not create session")
return
}
http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds())))
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true})
}
func (server *Server) handleLogout(writer http.ResponseWriter, request *http.Request) {
if cookie, err := request.Cookie(sessionCookieName); err == nil {
server.sessions.Delete(cookie.Value)
}
http.SetCookie(writer, server.sessionCookie("", -1))
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: false})
}
func (server *Server) handleSession(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: server.authenticated(request)})
}
func (server *Server) sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: server.settings.CookieSecure,
SameSite: http.SameSiteStrictMode,
}
}
func (server *Server) clientAddress(request *http.Request) string {
if server.settings.TrustProxyHeaders {
if realIP := strings.TrimSpace(request.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
+99
View File
@@ -0,0 +1,99 @@
package server
import (
"net/http"
"ddc/internal/jobs"
)
type createJobRequest struct {
URL string `json:"url"`
AutoExtract bool `json:"autoExtract"`
}
type extractRequest struct {
Path string `json:"path"`
}
type diskResponse struct {
AvailableBytes int64 `json:"availableBytes"`
ReservedBytes int64 `json:"reservedBytes"`
}
type startedResponse struct {
Started bool `json:"started"`
}
func (server *Server) handleLibraries(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.manager.Destinations())
}
func (server *Server) handleDisk(writer http.ResponseWriter, _ *http.Request) {
available, err := server.manager.AvailableBytes()
if err != nil {
writeFailure(writer, err)
return
}
writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()})
}
func (server *Server) handleListJobs(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.manager.List())
}
func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) {
var body createJobRequest
if err := decodeJSON(request, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid request body")
return
}
snapshot, err := server.manager.Create(body.URL, body.AutoExtract)
if err != nil {
writeFailure(writer, err)
return
}
writeJSON(writer, http.StatusCreated, snapshot)
}
func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) {
tree, err := server.manager.Files(request.PathValue("id"))
if err != nil {
writeFailure(writer, err)
return
}
writeJSON(writer, http.StatusOK, tree)
}
func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) {
var body extractRequest
if err := decodeJSON(request, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid request body")
return
}
if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil {
writeFailure(writer, err)
return
}
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
}
func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) {
var body jobs.MoveRequest
if err := decodeJSON(request, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid request body")
return
}
if err := server.manager.Move(request.PathValue("id"), body); err != nil {
writeFailure(writer, err)
return
}
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
}
func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) {
if err := server.manager.Delete(request.PathValue("id")); err != nil {
writeFailure(writer, err)
return
}
writer.WriteHeader(http.StatusNoContent)
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"net/http"
"net/url"
"strings"
)
const (
sessionCookieName = "ddc_session"
maxRequestBodyBytes = 64 << 10
contentSecurityPolicy = "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
)
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
header := writer.Header()
header.Set("Content-Security-Policy", contentSecurityPolicy)
header.Set("X-Content-Type-Options", "nosniff")
header.Set("X-Frame-Options", "DENY")
header.Set("Referrer-Policy", "no-referrer")
header.Set("Cross-Origin-Opener-Policy", "same-origin")
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if strings.HasPrefix(request.URL.Path, "/api/") {
header.Set("Cache-Control", "no-store")
}
next.ServeHTTP(writer, request)
})
}
func protectStateChanges(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet || request.Method == http.MethodHead {
next.ServeHTTP(writer, request)
return
}
if !isSameOrigin(request) {
writeError(writer, http.StatusForbidden, "cross-origin request rejected")
return
}
if request.Method == http.MethodPost && !strings.HasPrefix(request.Header.Get("Content-Type"), "application/json") {
writeError(writer, http.StatusUnsupportedMediaType, "content type must be application/json")
return
}
request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBodyBytes)
next.ServeHTTP(writer, request)
})
}
func isSameOrigin(request *http.Request) bool {
origin := request.Header.Get("Origin")
if origin == "" {
fetchSite := request.Header.Get("Sec-Fetch-Site")
return fetchSite == "" || fetchSite == "same-origin" || fetchSite == "none"
}
parsed, err := url.Parse(origin)
return err == nil && strings.EqualFold(parsed.Host, request.Host)
}
func (server *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !server.authenticated(request) {
writeError(writer, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(writer, request)
})
}
func (server *Server) authenticated(request *http.Request) bool {
cookie, err := request.Cookie(sessionCookieName)
return err == nil && server.sessions.Valid(cookie.Value)
}
+47
View File
@@ -0,0 +1,47 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"ddc/internal/diskspace"
"ddc/internal/files"
"ddc/internal/jobs"
"ddc/internal/preferences"
)
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
json.NewEncoder(writer).Encode(payload)
}
func writeError(writer http.ResponseWriter, status int, message string) {
writeJSON(writer, status, map[string]string{"error": message})
}
func writeFailure(writer http.ResponseWriter, err error) {
writeError(writer, statusFor(err), err.Error())
}
func decodeJSON(request *http.Request, target any) error {
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}
func statusFor(err error) int {
switch {
case errors.Is(err, jobs.ErrJobNotFound):
return http.StatusNotFound
case errors.Is(err, jobs.ErrJobBusy), errors.Is(err, files.ErrDestinationExists):
return http.StatusConflict
case errors.Is(err, jobs.ErrInvalidRequest), errors.Is(err, preferences.ErrInvalid):
return http.StatusBadRequest
case errors.Is(err, diskspace.ErrInsufficientSpace):
return http.StatusInsufficientStorage
default:
return http.StatusInternalServerError
}
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"net/http"
"strings"
"time"
"ddc/internal/auth"
"ddc/internal/config"
"ddc/internal/jobs"
"ddc/web"
)
const (
maxLoginFailures = 5
loginLockout = 15 * time.Minute
)
type Server struct {
settings config.Settings
sessions *auth.SessionStore
limiter *auth.LoginLimiter
manager *jobs.Manager
}
func New(settings config.Settings, sessions *auth.SessionStore, manager *jobs.Manager) http.Handler {
server := &Server{
settings: settings,
sessions: sessions,
limiter: auth.NewLoginLimiter(maxLoginFailures, loginLockout),
manager: manager,
}
return server.routes()
}
func (server *Server) routes() http.Handler {
api := http.NewServeMux()
api.HandleFunc("POST /api/logout", server.handleLogout)
api.HandleFunc("GET /api/libraries", server.handleLibraries)
api.HandleFunc("GET /api/disk", server.handleDisk)
api.HandleFunc("GET /api/settings", server.handleGetSettings)
api.HandleFunc("POST /api/settings", server.handleUpdateSettings)
api.HandleFunc("GET /api/jobs", server.handleListJobs)
api.HandleFunc("POST /api/jobs", server.handleCreateJob)
api.HandleFunc("GET /api/jobs/{id}/files", server.handleJobFiles)
api.HandleFunc("POST /api/jobs/{id}/extract", server.handleExtract)
api.HandleFunc("POST /api/jobs/{id}/move", server.handleMove)
api.HandleFunc("DELETE /api/jobs/{id}", server.handleDeleteJob)
root := http.NewServeMux()
root.HandleFunc("POST /api/login", server.handleLogin)
root.HandleFunc("GET /api/session", server.handleSession)
root.HandleFunc("GET /healthz", handleHealth)
root.Handle("/api/", server.requireSession(api))
root.Handle("/", staticHandler())
return securityHeaders(protectStateChanges(root))
}
func staticHandler() http.Handler {
fileServer := http.FileServerFS(web.Static())
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/" && strings.HasSuffix(request.URL.Path, "/") {
http.NotFound(writer, request)
return
}
fileServer.ServeHTTP(writer, request)
})
}
func handleHealth(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}
+38
View File
@@ -0,0 +1,38 @@
package server
import (
"net/http"
"ddc/internal/preferences"
)
type settingsResponse struct {
Settings preferences.Preferences `json:"settings"`
DownloadDir string `json:"downloadDir"`
LibraryRoots []string `json:"libraryRoots"`
}
func (server *Server) handleGetSettings(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.settingsSnapshot())
}
func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) {
var body preferences.Preferences
if err := decodeJSON(request, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid request body")
return
}
if err := server.manager.UpdatePreferences(body); err != nil {
writeFailure(writer, err)
return
}
writeJSON(writer, http.StatusOK, server.settingsSnapshot())
}
func (server *Server) settingsSnapshot() settingsResponse {
return settingsResponse{
Settings: server.manager.Preferences(),
DownloadDir: server.settings.DownloadDir,
LibraryRoots: server.settings.LibraryRoots,
}
}