feat: whole bunch of shit

This commit is contained in:
Max Richter
2025-08-17 16:35:52 +02:00
parent 69c2550f44
commit b3c01bb43d
36 changed files with 878 additions and 12 deletions

View File

@@ -0,0 +1,23 @@
package fsx
import (
"mime"
"path/filepath"
"strings"
)
func ContentTypeFor(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".md", ".markdown", ".mdown":
return "application/markdown"
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
switch ext {
case ".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".xml", ".csv":
return "text/plain; charset=utf-8"
}
return "application/octet-stream"
}

View File

@@ -0,0 +1,56 @@
package fsx
import (
"errors"
"path/filepath"
"strings"
)
func CleanURLLike(p string) string {
p = strings.TrimSpace(p)
if p == "" || p == "/" {
return "/"
}
parts := []string{}
for _, seg := range strings.Split(strings.ReplaceAll(p, "\\", "/"), "/") {
switch seg {
case "", ".":
continue
case "..":
if len(parts) > 0 {
parts = parts[:len(parts)-1]
}
default:
parts = append(parts, seg)
}
}
return "/" + strings.Join(parts, "/")
}
func SafeRel(root, requested string) (string, error) {
s := CleanURLLike(requested)
if strings.HasPrefix(s, "/") {
s = strings.TrimPrefix(s, "/")
}
full := filepath.Join(root, filepath.FromSlash(s))
rel, err := filepath.Rel(root, full)
if err != nil {
return "", err
}
if rel == "." {
return "/", nil
}
sep := string(filepath.Separator)
if strings.HasPrefix(rel, "..") || strings.Contains(rel, ".."+sep) {
return "", errors.New("path escapes root")
}
return "/" + filepath.ToSlash(rel), nil
}
func ResponsePath(root, full string) string {
rel, err := filepath.Rel(root, full)
if err != nil || rel == "." {
return "/"
}
return "/" + filepath.ToSlash(rel)
}

View File

@@ -0,0 +1,171 @@
// Package handlers provides HTTP handlers for the file system.
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"git.max-richter.dev/max/marka/parser"
"git.max-richter.dev/max/marka/server/internal/fsx"
"git.max-richter.dev/max/marka/server/internal/httpx"
)
type File struct{ root string }
func NewFile(root string) http.Handler { return &File{root: root} }
func (h *File) ServeHTTP(w http.ResponseWriter, r *http.Request) {
reqPath := r.URL.Path
if reqPath == "" {
reqPath = "/"
}
cleanRel, err := fsx.SafeRel(h.root, reqPath)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err)
return
}
target := filepath.Join(h.root, filepath.FromSlash(cleanRel))
switch r.Method {
case http.MethodGet:
h.get(w, r, target)
case http.MethodPost:
h.post(w, r, target)
default:
httpx.WriteError(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
}
func read(path string) any {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
res, err := parser.ParseFile(string(data))
if err != nil {
return nil
}
return res
}
func (h *File) get(w http.ResponseWriter, r *http.Request, target string) {
fi, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
httpx.WriteError(w, http.StatusNotFound, errors.New("not found"))
} else {
httpx.WriteError(w, http.StatusInternalServerError, err)
}
return
}
if fi.IsDir() {
entries, err := os.ReadDir(target)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
type item struct {
Name string `json:"name"`
Path string `json:"path"`
Content any `json:"content"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
}
out := make([]item, 0, len(entries))
for _, e := range entries {
info, _ := e.Info()
out = append(out, item{
Name: e.Name(),
Path: fsx.ResponsePath(h.root, filepath.Join(target, e.Name())),
IsDir: e.IsDir(),
Content: read(filepath.Join(target, e.Name())),
Size: sizeOrZero(info),
ModTime: modTimeOrZero(info),
})
}
httpx.WriteJSON(w, http.StatusOK, out)
return
}
f, err := os.Open(target)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
defer f.Close()
contentType := fsx.ContentTypeFor(target)
if contentType == "application/markdown" {
data, err := io.ReadAll(f)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
res, err := parser.ParseFile(string(data))
if err != nil {
fmt.Println(err)
}
w.Header().Set("Content-Type", "application/json")
httpx.WriteJSON(w, http.StatusOK, res)
return
}
w.Header().Set("Content-Type", contentType)
http.ServeContent(w, r, filepath.Base(target), fi.ModTime(), f)
}
func (h *File) post(w http.ResponseWriter, r *http.Request, target string) {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
tmp := target + ".tmp~"
f, err := os.Create(tmp)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
if _, err := io.Copy(f, r.Body); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Location", r.URL.Path)
w.WriteHeader(http.StatusCreated)
}
func sizeOrZero(fi os.FileInfo) int64 {
if fi == nil {
return 0
}
return fi.Size()
}
func modTimeOrZero(fi os.FileInfo) time.Time {
if fi == nil {
return time.Time{}
}
return fi.ModTime()
}

View File

@@ -0,0 +1,20 @@
package httpx
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Error string `json:"error"`
}
func WriteJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, code int, err error) {
WriteJSON(w, code, ErrorResponse{Error: err.Error()})
}

View File

@@ -0,0 +1,11 @@
package httpx
import "net/http"
type Router struct{ mux *http.ServeMux }
func NewRouter() *Router { return &Router{mux: http.NewServeMux()} }
func (r *Router) Handle(pattern string, h http.Handler) { r.mux.Handle(pattern, h) }
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}