feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 7s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board
- Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api
- Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme
- PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven
- Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
Hermes
2026-07-14 10:13:51 -03:00
commit 041c3fab4b
44 changed files with 5931 additions and 0 deletions

95
internal/api/http.go Normal file
View File

@@ -0,0 +1,95 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"strings"
"havenllo/internal/domain"
)
const maxJSONBody = 1 << 20
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: apiError{Code: code, Message: message}})
}
func writeDomainError(w http.ResponseWriter, err error) {
var validation domain.ValidationError
switch {
case errors.Is(err, domain.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "resource not found")
case errors.Is(err, domain.ErrLastList):
writeError(w, http.StatusConflict, "last_list", err.Error())
case errors.As(err, &validation):
writeError(w, http.StatusUnprocessableEntity, "validation_error", validation.Message)
default:
writeError(w, http.StatusInternalServerError, "internal_error", "an unexpected error occurred")
}
}
func requireJSON(w http.ResponseWriter, r *http.Request) bool {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json")
return false
}
return true
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeDecodeError(w, err)
return false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeError(w, http.StatusBadRequest, "invalid_json", "request body must contain one JSON value")
return false
}
return true
}
func writeDecodeError(w http.ResponseWriter, err error) {
var syntaxError *json.SyntaxError
var typeError *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxError):
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
case errors.Is(err, io.ErrUnexpectedEOF):
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
case errors.As(err, &typeError):
writeError(w, http.StatusBadRequest, "invalid_json", fmt.Sprintf("invalid value for %s", typeError.Field))
case strings.HasPrefix(err.Error(), "json: unknown field "):
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains an unknown field")
case errors.Is(err, io.EOF):
writeError(w, http.StatusBadRequest, "invalid_json", "request body must not be empty")
case strings.Contains(err.Error(), "http: request body too large"):
writeError(w, http.StatusBadRequest, "invalid_json", "request body is too large")
default:
writeError(w, http.StatusBadRequest, "invalid_json", "request body is invalid")
}
}
func pathID(w http.ResponseWriter, r *http.Request, key string) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue(key), 10, 64)
if err != nil || id < 1 {
writeError(w, http.StatusBadRequest, "invalid_id", "resource ID must be a positive integer")
return 0, false
}
return id, true
}