Split backend into internal packages by responsibility

All Go files lived flat in backend/ as one package main. Move store,
latest-chapter polling, sessions, HTTP middleware, the JSON API, the
userscript handler, and the web UI (with its templates/static assets)
into backend/internal/{store,latest,session,httpmw,api,userscript,web},
each with an exported API. main.go becomes the composition root wiring
them into newRouter; root-level tests cover the assembled router while
package-local tests cover unit behavior. Update Dockerfile/.dockerignore
for the new internal/ tree and CLAUDE.md to describe the layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:41:38 +07:00
parent e250762ea6
commit eeb601cbe2
36 changed files with 971 additions and 843 deletions
+35 -28
View File
@@ -11,6 +11,13 @@ import (
"strings"
"syscall"
"time"
"mangabm/backend/internal/api"
"mangabm/backend/internal/httpmw"
"mangabm/backend/internal/latest"
"mangabm/backend/internal/store"
"mangabm/backend/internal/userscript"
"mangabm/backend/internal/web"
)
// Config holds all runtime settings, sourced from environment variables.
@@ -149,22 +156,22 @@ func loadConfig() Config {
// newRouter wires routes and middleware. CORS is the outermost layer so
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public.
func newRouter(store *Store, cfg Config) http.Handler {
func newRouter(s *store.Store, cfg Config) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.HandleFunc("GET /healthz", api.Healthz)
// Outside withAuth (the updater sends no Authorization header) and outside
// the WEB_PASSWORD gate (the script must be installable either way). The
// path segment carries the token instead.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath))
// Outside httpmw.Auth (the updater sends no Authorization header) and
// outside the WEB_PASSWORD gate (the script must be installable either
// way). The path segment carries the token instead.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
h := &bookmarkHandler{store: store}
h := &api.Handler{Store: s}
protected := http.NewServeMux()
protected.HandleFunc("GET /bookmarks", h.list)
protected.HandleFunc("PUT /bookmarks/{key}", h.put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.delete)
protected.HandleFunc("GET /bookmarks", h.List)
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
auth := withAuth(cfg.Token, protected)
auth := httpmw.Auth(cfg.Token, protected)
mux.Handle("/bookmarks", auth)
mux.Handle("/bookmarks/", auth)
@@ -172,14 +179,14 @@ func newRouter(store *Store, cfg Config) http.Handler {
// deployment that forgets WEB_PASSWORD exposes nothing rather than
// exposing an unprotected list.
if cfg.WebPassword != "" {
web, err := newWebHandler(store, cfg)
wh, err := web.New(s, cfg.Token, cfg.WebPassword)
if err != nil {
log.Fatalf("web handler: %v", err)
}
web.register(mux)
wh.Register(mux)
}
return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux)))
return httpmw.CORS(cfg.AllowedOrigins, httpmw.Gzip(guardEmptyUserscriptToken(mux)))
}
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
@@ -203,22 +210,22 @@ func main() {
log.Fatal("API_TOKEN is required")
}
store, err := OpenStore(cfg.DBPath)
s, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer store.Close()
defer s.Close()
// The poller is off the request path entirely: if it cannot start, the
// service still serves bookmarks and the userscript still captures latest
// chapters on its own.
pollCtx, stopPoll := context.WithCancel(context.Background())
defer stopPoll()
startLatestPoller(pollCtx, store, cfg.LatestPoll)
startLatestPoller(pollCtx, s, cfg.LatestPoll)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: newRouter(store, cfg),
Handler: newRouter(s, cfg),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -248,24 +255,24 @@ func main() {
// HTTP client cannot be built. Any problem here is logged and skipped: this
// feature going missing degrades the service to userscript-only latest-chapter
// tracking, which is exactly how it behaved before.
func startLatestPoller(ctx context.Context, store *Store, cfg LatestPoll) {
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config")
return
}
f, err := newTLSFetcher()
f, err := latest.NewTLSFetcher()
if err != nil {
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
return
}
p := &latestPoller{
store: store,
fetch: f,
now: time.Now,
cooldown: cfg.Cooldown,
interval: cfg.Interval,
stagger: cfg.Stagger,
batch: cfg.Batch,
p := &latest.Poller{
Store: s,
Fetch: f,
Now: time.Now,
Cooldown: cfg.Cooldown,
Interval: cfg.Interval,
Stagger: cfg.Stagger,
Batch: cfg.Batch,
}
go p.Run(ctx)
}