Initial DDC release
This commit is contained in:
commit
434f6441da
57 files changed
+4606
No files matched your search
Executable
+83
@@ -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
|
||||
}
|
||||
Executable
+99
@@ -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)
|
||||
}
|
||||
Executable
+73
@@ -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)
|
||||
}
|
||||
Executable
+47
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+73
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user