dropped fiber for std http + gorilla websocket

Session serialization will use gob encoding instead of json.
Binary size will likely be reduced.
General backend code refactoring.
This commit is contained in:
2023-07-26 11:48:54 +02:00
parent 82b22db7ae
commit e1510d28d2
11 changed files with 351 additions and 251 deletions

View File

@@ -1,14 +1,13 @@
package internal
import (
"encoding/gob"
"errors"
"fmt"
"log"
"os"
"sync"
"github.com/goccy/go-json"
"github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
)
@@ -93,25 +92,36 @@ func (m *MemoryDB) All() *[]ProcessResponse {
func (m *MemoryDB) Persist() {
running := m.All()
session, err := json.Marshal(Session{
Processes: *running,
})
fd, err := os.Create("session.dat")
if err != nil {
log.Println(cli.Red, "Failed to persist database", cli.Reset)
return
log.Println(cli.Red, "Failed to persist session", cli.Reset)
}
err = os.WriteFile("session.dat", session, 0700)
if err != nil {
log.Println(cli.Red, "Failed to persist database", cli.Reset)
session := Session{
Processes: *running,
}
err = gob.NewEncoder(fd).Encode(session)
if err != nil {
log.Println(cli.Red, "Failed to persist session", cli.Reset)
}
log.Println(cli.BgBlue, "Successfully serialized session", cli.Reset)
}
// WIP: Restore a persisted state
func (m *MemoryDB) Restore() {
feed, _ := os.ReadFile("session.dat")
fd, err := os.Open("session.dat")
if err != nil {
return
}
session := Session{}
json.Unmarshal(feed, &session)
err = gob.NewDecoder(fd).Decode(&session)
if err != nil {
return
}
for _, proc := range session.Processes {
m.table.Store(proc.Id, &Process{
@@ -122,4 +132,6 @@ func (m *MemoryDB) Restore() {
DB: m,
})
}
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
}

15
server/middleware/cors.go Normal file
View File

@@ -0,0 +1,15 @@
package middlewares
import "net/http"
// Middleware for applying CORS policy for ALL hosts and for
// allowing ALL request headers.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Credentials", "true")
next.ServeHTTP(w, r)
})
}

View File

@@ -2,10 +2,10 @@ package middlewares
import (
"fmt"
"net/http"
"os"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
@@ -14,37 +14,49 @@ const (
TOKEN_COOKIE_NAME = "jwt"
)
var Authenticated = func(c *fiber.Ctx) error {
if !config.Instance().GetConfig().RequireAuth {
return c.Next()
}
cookie := c.Cookies(TOKEN_COOKIE_NAME)
if cookie == "" {
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
}
token, _ := jwt.Parse(cookie, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
func Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !config.Instance().GetConfig().RequireAuth {
next.ServeHTTP(w, r)
return
}
return []byte(os.Getenv("JWT_SECRET")), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
cookie, err := r.Cookie(TOKEN_COOKIE_NAME)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
if time.Now().After(expiresAt) {
return c.Status(fiber.StatusBadRequest).SendString("expired token")
if cookie == nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
} else {
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
}
return c.Next()
token, _ := jwt.Parse(cookie.Value, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(os.Getenv("JWTSECRET")), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if time.Now().After(expiresAt) {
http.Error(w, "token expired", http.StatusBadRequest)
return
}
} else {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,93 @@
package middlewares
import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
)
type SpaHandler struct {
Entrypoint string
Filesystem fs.FS
routes []string
}
func NewSpaHandler(index string, fs fs.FS) *SpaHandler {
return &SpaHandler{
Entrypoint: index,
Filesystem: fs,
}
}
func (s *SpaHandler) AddClientRoute(route string) *SpaHandler {
s.routes = append(s.routes, route)
return s
}
// Handler for serving a compiled react frontend
// each client-side routes must be provided
func (s *SpaHandler) Handler() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(
w,
http.StatusText(http.StatusMethodNotAllowed),
http.StatusMethodNotAllowed,
)
return
}
path := filepath.Clean(r.URL.Path)
// basically all frontend routes are needed :/
hasRoute := false
for _, route := range s.routes {
hasRoute = strings.HasPrefix(path, route)
if hasRoute {
break
}
}
if path == "/" || hasRoute {
path = s.Entrypoint
}
path = strings.TrimPrefix(path, "/")
file, err := s.Filesystem.Open(path)
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
contentType := mime.TypeByExtension(filepath.Ext(path))
w.Header().Set("Content-Type", contentType)
if strings.HasPrefix(path, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=2592000")
}
stat, err := file.Stat()
if err == nil && stat.Size() > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
}
w.WriteHeader(http.StatusOK)
io.Copy(w, file)
})
}

View File

@@ -2,7 +2,7 @@ package rest
import (
"encoding/hex"
"errors"
"encoding/json"
"net/http"
"os"
"path/filepath"
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
@@ -69,18 +69,20 @@ type ListRequest struct {
OrderBy string `json:"orderBy"`
}
func ListDownloaded(ctx *fiber.Ctx) error {
func ListDownloaded(w http.ResponseWriter, r *http.Request) {
root := config.Instance().GetConfig().DownloadPath
req := new(ListRequest)
err := ctx.BodyParser(req)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return err
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files, err := walkDir(filepath.Join(root, req.SubDir))
if err != nil {
return err
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.OrderBy == "modtime" {
@@ -89,45 +91,55 @@ func ListDownloaded(ctx *fiber.Ctx) error {
})
}
ctx.Status(http.StatusOK)
return ctx.JSON(files)
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(files)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
type DeleteRequest = DirectoryEntry
func DeleteFile(ctx *fiber.Ctx) error {
func DeleteFile(w http.ResponseWriter, r *http.Request) {
req := new(DeleteRequest)
err := ctx.BodyParser(req)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return err
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sum := utils.ShaSumString(req.Path)
if sum != req.SHASum {
return errors.New("shasum mismatch")
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
err = os.Remove(req.Path)
if err != nil {
return err
http.Error(w, "shasum mismatch", http.StatusBadRequest)
return
}
ctx.Status(fiber.StatusOK)
return ctx.JSON("ok")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("ok")
}
func SendFile(ctx *fiber.Ctx) error {
path := ctx.Params("id")
func SendFile(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "id")
if path == "" {
return errors.New("inexistent path")
http.Error(w, "inexistent path", http.StatusBadRequest)
return
}
decoded, err := hex.DecodeString(path)
if err != nil {
return err
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decodedStr := string(decoded)
root := config.Instance().GetConfig().DownloadPath
@@ -138,26 +150,28 @@ func SendFile(ctx *fiber.Ctx) error {
// "Content-Disposition",
// "inline; filename="+filepath.Base(decodedStr),
// )
ctx.SendStatus(fiber.StatusOK)
return ctx.SendFile(decodedStr)
http.ServeFile(w, r, decodedStr)
}
return ctx.SendStatus(fiber.StatusUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
}
type LoginRequest struct {
Secret string `json:"secret"`
}
func Login(ctx *fiber.Ctx) error {
func Login(w http.ResponseWriter, r *http.Request) {
req := new(LoginRequest)
err := ctx.BodyParser(req)
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return ctx.SendStatus(fiber.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if config.Instance().GetConfig().RPCSecret != req.Secret {
return ctx.SendStatus(fiber.StatusBadRequest)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
expiresAt := time.Now().Add(time.Hour * 24 * 30)
@@ -168,30 +182,31 @@ func Login(ctx *fiber.Ctx) error {
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil {
return ctx.SendStatus(fiber.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ctx.Cookie(&fiber.Cookie{
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HTTPOnly: true,
HttpOnly: true,
Secure: false,
Expires: expiresAt, // 30 days
Value: tokenString,
Path: "/",
})
}
return ctx.SendStatus(fiber.StatusOK)
http.SetCookie(w, cookie)
}
func Logout(ctx *fiber.Ctx) error {
ctx.Cookie(&fiber.Cookie{
func Logout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: TOKEN_COOKIE_NAME,
HTTPOnly: true,
HttpOnly: true,
Secure: false,
Expires: time.Now(),
Value: "",
Path: "/",
})
}
return ctx.SendStatus(fiber.StatusOK)
http.SetCookie(w, cookie)
}

53
server/rpc/handlers.go Normal file
View File

@@ -0,0 +1,53 @@
package rpc
import (
"io"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader websocket.Upgrader
func WebSocket(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer c.Close()
// notify client that conn is open and ok
c.WriteJSON(struct{ Status string }{Status: "connected"})
for {
mtype, reader, err := c.NextReader()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
break
}
res := newRequest(reader).Call()
writer, err := c.NextWriter(mtype)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
break
}
io.Copy(writer, res)
}
}
func Post(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
res := newRequest(r.Body).Call()
_, err := io.Copy(w, res)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

View File

@@ -1,4 +1,4 @@
package server
package rpc
import (
"bytes"
@@ -13,7 +13,7 @@ type rpcRequest struct {
done chan bool
}
func NewRPCRequest(r io.Reader) *rpcRequest {
func newRequest(r io.Reader) *rpcRequest {
var buf bytes.Buffer
done := make(chan bool)
return &rpcRequest{r, &buf, done}

View File

@@ -3,7 +3,6 @@ package server
import (
"context"
"fmt"
"io"
"io/fs"
"log"
"net/http"
@@ -13,16 +12,21 @@ import (
"syscall"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/websocket/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
)
type serverConfig struct {
frontend fs.FS
port int
db *internal.MemoryDB
mq *internal.MessageQueue
}
func RunBlocking(port int, frontend fs.FS) {
var db internal.MemoryDB
db.Restore()
@@ -30,80 +34,64 @@ func RunBlocking(port int, frontend fs.FS) {
mq := internal.NewMessageQueue()
go mq.Subscriber()
service := ytdlpRPC.Container(&db, mq)
rpc.Register(service)
app := fiber.New()
app.Use(cors.New())
app.Use("/", filesystem.New(filesystem.Config{
Root: http.FS(frontend),
}))
// Client side routes
app.Get("/settings", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
app.Get("/archive", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
app.Get("/login", func(c *fiber.Ctx) error {
return c.Redirect("/")
srv := newServer(serverConfig{
frontend: frontend,
port: port,
db: &db,
mq: mq,
})
// Archive routes
archive := app.Group("archive", middlewares.Authenticated)
archive.Post("/downloaded", rest.ListDownloaded)
archive.Post("/delete", rest.DeleteFile)
archive.Get("/d/:id", rest.SendFile)
// Authentication routes
app.Post("/auth/login", rest.Login)
app.Get("/auth/logout", rest.Logout)
// RPC handlers
// websocket
rpc := app.Group("/rpc", middlewares.Authenticated)
rpc.Get("/ws", websocket.New(func(c *websocket.Conn) {
c.WriteMessage(websocket.TextMessage, []byte(`{
"status": "connected"
}`))
for {
mtype, reader, err := c.NextReader()
if err != nil {
break
}
res := NewRPCRequest(reader).Call()
writer, err := c.NextWriter(mtype)
if err != nil {
break
}
io.Copy(writer, res)
}
}))
// http-post
rpc.Post("/http", func(c *fiber.Ctx) error {
reader := c.Context().RequestBodyStream()
writer := c.Response().BodyWriter()
res := NewRPCRequest(reader).Call()
io.Copy(writer, res)
return nil
})
app.Server().StreamRequestBody = true
go gracefulShutdown(app, &db)
go gracefulShutdown(srv, &db)
go autoPersist(time.Minute*5, &db)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
log.Fatal(srv.ListenAndServe())
}
func gracefulShutdown(app *fiber.App, db *internal.MemoryDB) {
func newServer(c serverConfig) *http.Server {
service := ytdlpRPC.Container(c.db, c.mq)
rpc.Register(service)
r := chi.NewRouter()
r.Use(middlewares.CORS)
r.Use(middleware.Logger)
sh := middlewares.NewSpaHandler("index.html", c.frontend)
sh.AddClientRoute("/settings")
sh.AddClientRoute("/archive")
sh.AddClientRoute("/login")
r.Get("/*", sh.Handler())
// Archive routes
r.Route("/archive", func(r chi.Router) {
r.Use(middlewares.Authenticated)
r.Post("/downloaded", rest.ListDownloaded)
r.Post("/delete", rest.DeleteFile)
r.Get("/d/{id}", rest.SendFile)
})
// Authentication routes
r.Route("/auth", func(r chi.Router) {
r.Post("/login", rest.Login)
r.Get("/logout", rest.Logout)
})
// RPC handlers
r.Route("/rpc", func(r chi.Router) {
r.Use(middlewares.Authenticated)
r.Get("/ws", ytdlpRPC.WebSocket)
r.Post("/http", ytdlpRPC.Post)
})
return &http.Server{
Addr: fmt.Sprintf(":%d", c.port),
Handler: r,
}
}
func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt,
syscall.SIGTERM,
@@ -117,7 +105,7 @@ func gracefulShutdown(app *fiber.App, db *internal.MemoryDB) {
defer func() {
db.Persist()
stop()
app.ShutdownWithTimeout(time.Second * 5)
srv.Shutdown(context.TODO())
}()
}()
}