marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit 5f603fb84991b52ad00e9f75ecded99d83ed037a
parent 42f1ecfc4ab7fe7a3c0644091b8cf4c4b6876d8b
Author: David Eisinger <[email protected]>
Date:   Tue,  7 Oct 2025 00:07:48 -0400

flags

Diffstat:
MREADME.md | 12+++++++++---
Acmd/main_test.go | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/marky.go | 33++++++++++++++++++++++++++++++---
Minternal/api.go | 62++++++++++++++++++++++++++++++++++++--------------------------
Minternal/api_auth_test.go | 101++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Minternal/bookmarks_api_test.go | 184++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Ainternal/disable_signup_test.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Minternal/more_api_tests.go | 217++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Minternal/static.go | 18+++++++++++++++++-
Minternal/web/app.js | 1+
Minternal/web/index.html | 4++--
11 files changed, 506 insertions(+), 263 deletions(-)

diff --git a/README.md b/README.md @@ -20,9 +20,15 @@ A simple web API for bookmarking websites, written in Go. It stores bookmarks in ## Run -- `./bin/marky` -- The database file is created automatically at `~/.marky/bookmarks.sql` and migrations are applied on startup. -- Open the web UI at: `http://localhost:8080/` +- `./bin/marky [options]` +- The database file is created automatically (default: `~/.marky/bookmarks.sql`) and migrations are applied on startup. +- Open the web UI at: `http://localhost:8080/` (or your configured address) + +Flags: +- `--addr` listen address, default `:8080` (e.g., `127.0.0.1:9090`) +- `--db` path to SQLite DB file (created if missing), default resolves to `~/.marky/bookmarks.sql` +- `--disable-signup` disable the `/signup` route and hide signup in the UI +- `--help` show usage and exit ## Authentication diff --git a/cmd/main_test.go b/cmd/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "bytes" + "context" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "syscall" + "testing" + "time" +) + +// Test that the logging middleware delegates to the next handler +// and emits a log line containing the method and path. +func TestLoggingMiddleware_EmitsAndDelegates(t *testing.T) { + // Not parallel: modifies global logger output + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + }) + + var buf bytes.Buffer + log.SetOutput(&buf) + defer log.SetOutput(os.Stderr) + + h := logging(next) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/hello", nil) + + h.ServeHTTP(rr, req) + + if !called { + t.Fatalf("expected next handler to be called") + } + if rr.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rr.Code) + } + + out := buf.String() + if !strings.Contains(out, "GET") || !strings.Contains(out, "/hello") { + t.Fatalf("expected log to contain method and path, got: %q", out) + } +} + +// TestMainLifecycle starts main() with ephemeral address and temp DB, +// waits for the listening log, then sends SIGTERM to trigger graceful shutdown. +func TestMainLifecycle_StartsAndShutsDown(t *testing.T) { + // Not parallel: uses Setenv and sends process signals + var buf bytes.Buffer + log.SetOutput(&buf) + defer log.SetOutput(os.Stderr) + + tempDB := t.TempDir() + "/test.db" + // Provide flags to main via os.Args. + oldArgs := os.Args + os.Args = []string{"marky", "--addr", "127.0.0.1:0", "--db", tempDB} + defer func() { os.Args = oldArgs }() + + done := make(chan struct{}) + go func() { + main() + close(done) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + for { + select { + case <-ctx.Done(): + t.Fatalf("did not observe listening log: %q", buf.String()) + default: + if strings.Contains(buf.String(), "marky API listening on") { + goto running + } + time.Sleep(10 * time.Millisecond) + } + } +running: + if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil { + t.Fatalf("send SIGTERM: %v", err) + } + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatalf("main did not shut down in time; logs: %q", buf.String()) + } +} diff --git a/cmd/marky.go b/cmd/marky.go @@ -2,6 +2,8 @@ package main import ( "context" + "flag" + "fmt" "log" "net/http" "os" @@ -13,24 +15,49 @@ import ( ) func main() { + // Resolve default DB path from internal package. + defaultDBPath, _ := dbpkg.DefaultPath() + + // Usage/help + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options]\n\n", os.Args[0]) + fmt.Fprintf(flag.CommandLine.Output(), "Options:\n") + flag.PrintDefaults() + } + + // Flags + addr := flag.String("addr", ":8080", "listen address (e.g. :8080, 127.0.0.1:8080)") + dbPath := flag.String("db", defaultDBPath, "path to SQLite DB file (created if missing)") + disableSignup := flag.Bool("disable-signup", false, "disable signup endpoint and hide from UI") + + // Also accept explicit --help to exit 0 with usage. + for _, a := range os.Args[1:] { + if a == "--help" { + flag.Usage() + return + } + } + + flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // Open database (creates and migrates if needed) - db, err := dbpkg.Open(ctx) + db, err := dbpkg.OpenAtPath(ctx, *dbPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() // Build the shared HTTP handler (API + UI) - h, err := dbpkg.NewHandler(db) + h, err := dbpkg.NewHandlerWithConfig(db, dbpkg.HandlerConfig{DisableSignup: *disableSignup}) if err != nil { log.Fatalf("build handler: %v", err) } srv := &http.Server{ - Addr: ":8080", + Addr: *addr, Handler: logging(h), ReadHeaderTimeout: 5 * time.Second, } diff --git a/internal/api.go b/internal/api.go @@ -13,34 +13,44 @@ import ( "time" ) -// NewHandler constructs the HTTP API handler, including the embedded web UI. -func NewHandler(db *DB) (http.Handler, error) { +// HandlerConfig controls optional features exposed by the HTTP handler. +type HandlerConfig struct { + DisableSignup bool +} + +// NewHandler constructs the HTTP API handler with default settings. +func NewHandler(db *DB) (http.Handler, error) { return NewHandlerWithConfig(db, HandlerConfig{}) } + +// NewHandlerWithConfig constructs the HTTP API handler, including the embedded web UI. +func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { mux := http.NewServeMux() // Auth endpoints - mux.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct{ Username, Password string } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - if err := ValidateUsername(req.Username); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - id, err := db.CreateUser(r.Context(), req.Username, req.Password) - if err != nil { - http.Error(w, "cannot create user", http.StatusConflict) - return - } - SetSession(w, id) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"username": req.Username}) - }) + if !cfg.DisableSignup { + mux.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct{ Username, Password string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if err := ValidateUsername(req.Username); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + id, err := db.CreateUser(r.Context(), req.Username, req.Password) + if err != nil { + http.Error(w, "cannot create user", http.StatusConflict) + return + } + SetSession(w, id) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"username": req.Username}) + }) + } mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -166,7 +176,7 @@ func NewHandler(db *DB) (http.Handler, error) { }) // Static web UI at "/" with user feeds overlay (/{username}.json and /{username}.rss) - static, err := StaticHandler() + static, err := StaticHandlerWithConfig(cfg) if err != nil { return nil, err } diff --git a/internal/api_auth_test.go b/internal/api_auth_test.go @@ -1,52 +1,63 @@ package internal import ( - "bytes" - "encoding/json" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "testing" + "bytes" + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "testing" ) func TestSignupLoginLogoutMe(t *testing.T) { - t.Parallel() - db, err := OpenAtPath(t.Context(), t.TempDir()+"/authz.db") - if err != nil { t.Fatalf("open: %v", err) } - t.Cleanup(func(){ _ = db.Close() }) - h, _ := NewHandler(db) - srv := httptest.NewServer(h) - defer srv.Close() - - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - - // /me unauth - resp, _ := http.Get(srv.URL+"/me") - if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("/me unauth got %d", resp.StatusCode) } - - // signup - b, _ := json.Marshal(map[string]string{"username":"bob","password":"pw"}) - r, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(b)) - r.Header.Set("Content-Type","application/json") - rs, _ := client.Do(r) - if rs.StatusCode != http.StatusOK { t.Fatalf("signup status %d", rs.StatusCode) } - rs.Body.Close() - - // /me auth - r2, _ := client.Get(srv.URL+"/me") - if r2.StatusCode != http.StatusOK { t.Fatalf("/me auth %d", r2.StatusCode) } - r2.Body.Close() - - // logout - r3, _ := http.NewRequest(http.MethodPost, srv.URL+"/logout", nil) - rs3, _ := client.Do(r3) - if rs3.StatusCode != http.StatusNoContent { t.Fatalf("logout %d", rs3.StatusCode) } - rs3.Body.Close() - - // /me again - r4, _ := client.Get(srv.URL+"/me") - if r4.StatusCode != http.StatusUnauthorized { t.Fatalf("/me after logout %d", r4.StatusCode) } - r4.Body.Close() + t.Parallel() + db, err := OpenAtPath(t.Context(), t.TempDir()+"/authz.db") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + h, _ := NewHandler(db) + srv := httptest.NewServer(h) + defer srv.Close() + + jar, _ := cookiejar.New(nil) + client := &http.Client{Jar: jar} + + // /me unauth + resp, _ := http.Get(srv.URL + "/me") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("/me unauth got %d", resp.StatusCode) + } + + // signup + b, _ := json.Marshal(map[string]string{"username": "bob", "password": "pw"}) + r, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + rs, _ := client.Do(r) + if rs.StatusCode != http.StatusOK { + t.Fatalf("signup status %d", rs.StatusCode) + } + rs.Body.Close() + + // /me auth + r2, _ := client.Get(srv.URL + "/me") + if r2.StatusCode != http.StatusOK { + t.Fatalf("/me auth %d", r2.StatusCode) + } + r2.Body.Close() + + // logout + r3, _ := http.NewRequest(http.MethodPost, srv.URL+"/logout", nil) + rs3, _ := client.Do(r3) + if rs3.StatusCode != http.StatusNoContent { + t.Fatalf("logout %d", rs3.StatusCode) + } + rs3.Body.Close() + + // /me again + r4, _ := client.Get(srv.URL + "/me") + if r4.StatusCode != http.StatusUnauthorized { + t.Fatalf("/me after logout %d", r4.StatusCode) + } + r4.Body.Close() } - diff --git a/internal/bookmarks_api_test.go b/internal/bookmarks_api_test.go @@ -1,97 +1,119 @@ package internal import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "strings" - "testing" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strings" + "testing" ) func TestBookmarksCRUDAndUnauthorized(t *testing.T) { - t.Parallel() - page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type","text/html") - io.WriteString(w, "<!doctype html><html><head><title>CRUD Title</title></head><body>body</body></html>") - })) - defer page.Close() + t.Parallel() + page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, "<!doctype html><html><head><title>CRUD Title</title></head><body>body</body></html>") + })) + defer page.Close() - db, err := OpenAtPath(t.Context(), t.TempDir()+"/crud.db") - if err != nil { t.Fatalf("open: %v", err) } - t.Cleanup(func(){ _ = db.Close() }) - h, _ := NewHandler(db) - srv := httptest.NewServer(h) - defer srv.Close() + db, err := OpenAtPath(t.Context(), t.TempDir()+"/crud.db") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + h, _ := NewHandler(db) + srv := httptest.NewServer(h) + defer srv.Close() - // Unauth GET /bookmarks → 401 - if resp, _ := http.Get(srv.URL+"/bookmarks"); resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("/bookmarks unauth %d", resp.StatusCode) - } - // Unauth POST /bookmarks → 401 - rp, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", strings.NewReader("{}")) - rp.Header.Set("Content-Type","application/json") - if rs, _ := http.DefaultClient.Do(rp); rs.StatusCode != http.StatusUnauthorized { - t.Fatalf("post unauth %d", rs.StatusCode) - } + // Unauth GET /bookmarks → 401 + if resp, _ := http.Get(srv.URL + "/bookmarks"); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("/bookmarks unauth %d", resp.StatusCode) + } + // Unauth POST /bookmarks → 401 + rp, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", strings.NewReader("{}")) + rp.Header.Set("Content-Type", "application/json") + if rs, _ := http.DefaultClient.Do(rp); rs.StatusCode != http.StatusUnauthorized { + t.Fatalf("post unauth %d", rs.StatusCode) + } - // Sign up client - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - body, _ := json.Marshal(map[string]string{"username":"crud","password":"pw"}) - rq, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(body)) - rq.Header.Set("Content-Type","application/json") - rsu, _ := client.Do(rq) - if rsu.StatusCode != http.StatusOK { t.Fatalf("signup %d", rsu.StatusCode) } - rsu.Body.Close() + // Sign up client + jar, _ := cookiejar.New(nil) + client := &http.Client{Jar: jar} + body, _ := json.Marshal(map[string]string{"username": "crud", "password": "pw"}) + rq, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(body)) + rq.Header.Set("Content-Type", "application/json") + rsu, _ := client.Do(rq) + if rsu.StatusCode != http.StatusOK { + t.Fatalf("signup %d", rsu.StatusCode) + } + rsu.Body.Close() - // Wrong content type → 415 - bad, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", strings.NewReader("not json")) - bad.Header.Set("Content-Type","text/plain") - rbad, _ := client.Do(bad) - if rbad.StatusCode != http.StatusBadRequest { t.Fatalf("ct status %d", rbad.StatusCode) } - rbad.Body.Close() + // Wrong content type → 415 + bad, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", strings.NewReader("not json")) + bad.Header.Set("Content-Type", "text/plain") + rbad, _ := client.Do(bad) + if rbad.StatusCode != http.StatusBadRequest { + t.Fatalf("ct status %d", rbad.StatusCode) + } + rbad.Body.Close() - // Create - create := map[string]any{"url": page.URL} - cbody, _ := json.Marshal(create) - rc, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(cbody)) - rc.Header.Set("Content-Type","application/json") - rcr, _ := client.Do(rc) - if rcr.StatusCode != http.StatusCreated { t.Fatalf("create %d", rcr.StatusCode) } - var created map[string]any - _ = json.NewDecoder(rcr.Body).Decode(&created) - rcr.Body.Close() - id := int(created["id"].(float64)) - if created["title"].(string) != "CRUD Title" { t.Fatalf("title inferred") } + // Create + create := map[string]any{"url": page.URL} + cbody, _ := json.Marshal(create) + rc, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(cbody)) + rc.Header.Set("Content-Type", "application/json") + rcr, _ := client.Do(rc) + if rcr.StatusCode != http.StatusCreated { + t.Fatalf("create %d", rcr.StatusCode) + } + var created map[string]any + _ = json.NewDecoder(rcr.Body).Decode(&created) + rcr.Body.Close() + id := int(created["id"].(float64)) + if created["title"].(string) != "CRUD Title" { + t.Fatalf("title inferred") + } - // Duplicate POST should 200 and touch updated_at - rc2, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(cbody)) - rc2.Header.Set("Content-Type","application/json") - rcr2, _ := client.Do(rc2) - if rcr2.StatusCode != http.StatusOK { t.Fatalf("dup %d", rcr2.StatusCode) } - rcr2.Body.Close() + // Duplicate POST should 200 and touch updated_at + rc2, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(cbody)) + rc2.Header.Set("Content-Type", "application/json") + rcr2, _ := client.Do(rc2) + if rcr2.StatusCode != http.StatusOK { + t.Fatalf("dup %d", rcr2.StatusCode) + } + rcr2.Body.Close() - // GET list and RSS - if _, err := client.Get(srv.URL+"/bookmarks"); err != nil { t.Fatalf("list err: %v", err) } - if resp, _ := client.Get(srv.URL+"/bookmarks.xml"); resp.StatusCode != 200 { t.Fatalf("rss %d", resp.StatusCode) } + // GET list and RSS + if _, err := client.Get(srv.URL + "/bookmarks"); err != nil { + t.Fatalf("list err: %v", err) + } + if resp, _ := client.Get(srv.URL + "/bookmarks.xml"); resp.StatusCode != 200 { + t.Fatalf("rss %d", resp.StatusCode) + } - // PUT update (preserve description when omitted) - put := map[string]any{"url": page.URL, "title":"Changed"} - pb, _ := json.Marshal(put) - pr, _ := http.NewRequest(http.MethodPut, srv.URL+"/bookmarks/"+strconvItoa(id), bytes.NewReader(pb)) - pr.Header.Set("Content-Type","application/json") - prr, _ := client.Do(pr) - if prr.StatusCode != 200 { t.Fatalf("put %d", prr.StatusCode) } - var upd map[string]any - _ = json.NewDecoder(prr.Body).Decode(&upd) - prr.Body.Close() - if upd["title"].(string) != "Changed" { t.Fatalf("put title") } + // PUT update (preserve description when omitted) + put := map[string]any{"url": page.URL, "title": "Changed"} + pb, _ := json.Marshal(put) + pr, _ := http.NewRequest(http.MethodPut, srv.URL+"/bookmarks/"+strconvItoa(id), bytes.NewReader(pb)) + pr.Header.Set("Content-Type", "application/json") + prr, _ := client.Do(pr) + if prr.StatusCode != 200 { + t.Fatalf("put %d", prr.StatusCode) + } + var upd map[string]any + _ = json.NewDecoder(prr.Body).Decode(&upd) + prr.Body.Close() + if upd["title"].(string) != "Changed" { + t.Fatalf("put title") + } - // DELETE - dr, _ := http.NewRequest(http.MethodDelete, srv.URL+"/bookmarks/"+strconvItoa(id), nil) - drr, _ := client.Do(dr) - if drr.StatusCode != 204 { t.Fatalf("delete %d", drr.StatusCode) } + // DELETE + dr, _ := http.NewRequest(http.MethodDelete, srv.URL+"/bookmarks/"+strconvItoa(id), nil) + drr, _ := client.Do(dr) + if drr.StatusCode != 204 { + t.Fatalf("delete %d", drr.StatusCode) + } } diff --git a/internal/disable_signup_test.go b/internal/disable_signup_test.go @@ -0,0 +1,45 @@ +package internal + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDisableSignup_RemovesRouteAndInjectsConfig(t *testing.T) { + t.Parallel() + db, err := OpenAtPath(t.Context(), t.TempDir()+"/nosign.db") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + h, err := NewHandlerWithConfig(db, HandlerConfig{DisableSignup: true}) + if err != nil { + t.Fatalf("handler: %v", err) + } + srv := httptest.NewServer(h) + defer srv.Close() + + // POST /signup should not be available + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader([]byte(`{"username":"x","password":"y"}`))) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("signup status: got %d want 404", resp.StatusCode) + } + resp.Body.Close() + + // Index should include injected config snippet + r2, _ := http.Get(srv.URL + "/") + if r2.StatusCode != 200 { + t.Fatalf("index %d", r2.StatusCode) + } + b, _ := io.ReadAll(r2.Body) + r2.Body.Close() + if !bytes.Contains(b, []byte("window.MARKY_CFG={disableSignup:true}")) { + t.Fatalf("expected config injection in index") + } +} diff --git a/internal/more_api_tests.go b/internal/more_api_tests.go @@ -1,124 +1,137 @@ package internal import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "testing" - "strconv" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + "testing" ) func TestPublicUserListingJSON(t *testing.T) { - t.Parallel() - db, _ := OpenAtPath(t.Context(), t.TempDir()+"/u.db") - t.Cleanup(func(){ _ = db.Close() }) - h, _ := NewHandler(db) - srv := httptest.NewServer(h) - defer srv.Close() + t.Parallel() + db, _ := OpenAtPath(t.Context(), t.TempDir()+"/u.db") + t.Cleanup(func() { _ = db.Close() }) + h, _ := NewHandler(db) + srv := httptest.NewServer(h) + defer srv.Close() - // Unknown user - if resp, _ := http.Get(srv.URL+"/u/nouser" ); resp.StatusCode != http.StatusNotFound { - t.Fatalf("/u/nouser expected 404, got %d", resp.StatusCode) - } + // Unknown user + if resp, _ := http.Get(srv.URL + "/u/nouser"); resp.StatusCode != http.StatusNotFound { + t.Fatalf("/u/nouser expected 404, got %d", resp.StatusCode) + } - // Create user and ensure empty list - _, _ = db.CreateUser(t.Context(), "eve", "pw") - if resp, _ := http.Get(srv.URL+"/u/eve" ); resp.StatusCode != 200 { - t.Fatalf("/u/eve %d", resp.StatusCode) - } + // Create user and ensure empty list + _, _ = db.CreateUser(t.Context(), "eve", "pw") + if resp, _ := http.Get(srv.URL + "/u/eve"); resp.StatusCode != 200 { + t.Fatalf("/u/eve %d", resp.StatusCode) + } } func TestBookmarksParamValidationAndRSSAccept(t *testing.T) { - t.Parallel() - page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type","text/html") - io.WriteString(w, "<!doctype html><html><head><title>P</title></head><body/></html>") - })) - defer page.Close() + t.Parallel() + page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, "<!doctype html><html><head><title>P</title></head><body/></html>") + })) + defer page.Close() - db, _ := OpenAtPath(t.Context(), t.TempDir()+"/p.db") - t.Cleanup(func(){ _ = db.Close() }) - h, _ := NewHandler(db) - srv := httptest.NewServer(h) - defer srv.Close() + db, _ := OpenAtPath(t.Context(), t.TempDir()+"/p.db") + t.Cleanup(func() { _ = db.Close() }) + h, _ := NewHandler(db) + srv := httptest.NewServer(h) + defer srv.Close() - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - // signup - body, _ := json.Marshal(map[string]string{"username":"param","password":"pw"}) - req, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(body)) - req.Header.Set("Content-Type","application/json") - rs, _ := client.Do(req) - if rs.StatusCode != 200 { t.Fatalf("signup %d", rs.StatusCode) } - rs.Body.Close() - // One bookmark - b2, _ := json.Marshal(map[string]any{"url": page.URL}) - rq, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(b2)) - rq.Header.Set("Content-Type","application/json") - cr, _ := client.Do(rq) - if cr.StatusCode != 201 { t.Fatalf("create %d", cr.StatusCode) } - cr.Body.Close() + jar, _ := cookiejar.New(nil) + client := &http.Client{Jar: jar} + // signup + body, _ := json.Marshal(map[string]string{"username": "param", "password": "pw"}) + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rs, _ := client.Do(req) + if rs.StatusCode != 200 { + t.Fatalf("signup %d", rs.StatusCode) + } + rs.Body.Close() + // One bookmark + b2, _ := json.Marshal(map[string]any{"url": page.URL}) + rq, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(b2)) + rq.Header.Set("Content-Type", "application/json") + cr, _ := client.Do(rq) + if cr.StatusCode != 201 { + t.Fatalf("create %d", cr.StatusCode) + } + cr.Body.Close() - // invalid limit - if resp, _ := client.Get(srv.URL+"/bookmarks?limit=abc"); resp.StatusCode != http.StatusBadRequest { - t.Fatalf("limit status %d", resp.StatusCode) - } - // invalid offset - if resp, _ := client.Get(srv.URL+"/bookmarks?offset=-1"); resp.StatusCode != http.StatusBadRequest { - t.Fatalf("offset status %d", resp.StatusCode) - } - // Accept RSS - req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/bookmarks", nil) - req2.Header.Set("Accept","application/rss+xml") - rs2, _ := client.Do(req2) - if rs2.StatusCode != 200 { t.Fatalf("accept rss %d", rs2.StatusCode) } - rs2.Body.Close() + // invalid limit + if resp, _ := client.Get(srv.URL + "/bookmarks?limit=abc"); resp.StatusCode != http.StatusBadRequest { + t.Fatalf("limit status %d", resp.StatusCode) + } + // invalid offset + if resp, _ := client.Get(srv.URL + "/bookmarks?offset=-1"); resp.StatusCode != http.StatusBadRequest { + t.Fatalf("offset status %d", resp.StatusCode) + } + // Accept RSS + req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/bookmarks", nil) + req2.Header.Set("Accept", "application/rss+xml") + rs2, _ := client.Do(req2) + if rs2.StatusCode != 200 { + t.Fatalf("accept rss %d", rs2.StatusCode) + } + rs2.Body.Close() } func TestArchiveFetchOwnerOnly(t *testing.T) { - t.Parallel() - // Page to fetch - page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type","text/html") - io.WriteString(w, "<!doctype html><html><head><title>A</title></head><body/></html>") - })) - defer page.Close() + t.Parallel() + // Page to fetch + page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, "<!doctype html><html><head><title>A</title></head><body/></html>") + })) + defer page.Close() - db, _ := OpenAtPath(t.Context(), t.TempDir()+"/arc.db") - t.Cleanup(func(){ _ = db.Close() }) - h, _ := NewHandler(db) - srv := httptest.NewServer(h) - defer srv.Close() + db, _ := OpenAtPath(t.Context(), t.TempDir()+"/arc.db") + t.Cleanup(func() { _ = db.Close() }) + h, _ := NewHandler(db) + srv := httptest.NewServer(h) + defer srv.Close() - // signup owner - j1, _ := cookiejar.New(nil) - c1 := &http.Client{Jar: j1} - s1, _ := json.Marshal(map[string]string{"username":"own","password":"pw"}) - rq1, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(s1)) - rq1.Header.Set("Content-Type","application/json") - _ , _ = c1.Do(rq1) - // create via DB without archive - u, _ := db.GetUserByUsername(t.Context(), "own") - bmk, err := db.CreateBookmark(t.Context(), u.ID, page.URL+"/priv", "PVT", nil) - if err != nil { t.Fatalf("create raw: %v", err) } - // owner can fetch with ?fetch=1 - resp, _ := c1.Get(srv.URL+"/bookmarks/"+strconv.Itoa(int(bmk.ID))+"/archive?fetch=1") - if resp.StatusCode != 200 { t.Fatalf("owner fetch %d", resp.StatusCode) } - resp.Body.Close() + // signup owner + j1, _ := cookiejar.New(nil) + c1 := &http.Client{Jar: j1} + s1, _ := json.Marshal(map[string]string{"username": "own", "password": "pw"}) + rq1, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(s1)) + rq1.Header.Set("Content-Type", "application/json") + _, _ = c1.Do(rq1) + // create via DB without archive + u, _ := db.GetUserByUsername(t.Context(), "own") + bmk, err := db.CreateBookmark(t.Context(), u.ID, page.URL+"/priv", "PVT", nil) + if err != nil { + t.Fatalf("create raw: %v", err) + } + // owner can fetch with ?fetch=1 + resp, _ := c1.Get(srv.URL + "/bookmarks/" + strconv.Itoa(int(bmk.ID)) + "/archive?fetch=1") + if resp.StatusCode != 200 { + t.Fatalf("owner fetch %d", resp.StatusCode) + } + resp.Body.Close() - // signup other user - j2, _ := cookiejar.New(nil) - c2 := &http.Client{Jar: j2} - s2, _ := json.Marshal(map[string]string{"username":"other","password":"pw"}) - rq2, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(s2)) - rq2.Header.Set("Content-Type","application/json") - _ , _ = c2.Do(rq2) - // other user cannot see private archive - resp2, _ := c2.Get(srv.URL+"/bookmarks/"+strconv.Itoa(int(bmk.ID))+"/archive") - if resp2.StatusCode != 404 { t.Fatalf("other archive %d", resp2.StatusCode) } - resp2.Body.Close() + // signup other user + j2, _ := cookiejar.New(nil) + c2 := &http.Client{Jar: j2} + s2, _ := json.Marshal(map[string]string{"username": "other", "password": "pw"}) + rq2, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(s2)) + rq2.Header.Set("Content-Type", "application/json") + _, _ = c2.Do(rq2) + // other user cannot see private archive + resp2, _ := c2.Get(srv.URL + "/bookmarks/" + strconv.Itoa(int(bmk.ID)) + "/archive") + if resp2.StatusCode != 404 { + t.Fatalf("other archive %d", resp2.StatusCode) + } + resp2.Body.Close() } + // no helper needed; use strconv.Itoa inline diff --git a/internal/static.go b/internal/static.go @@ -1,6 +1,7 @@ package internal import ( + "bytes" "embed" "io/fs" "net/http" @@ -13,7 +14,10 @@ var webAssets embed.FS // StaticHandler returns an http.Handler serving the embedded web UI with // history-fallback to index.html for client-side routes like "/{username}". -func StaticHandler() (http.Handler, error) { +func StaticHandler() (http.Handler, error) { return StaticHandlerWithConfig(HandlerConfig{}) } + +// StaticHandlerWithConfig serves the embedded UI, injecting runtime config. +func StaticHandlerWithConfig(cfg HandlerConfig) (http.Handler, error) { sub, err := fs.Sub(webAssets, "web") if err != nil { return nil, err @@ -24,6 +28,18 @@ func StaticHandler() (http.Handler, error) { if err != nil { return nil, err } + // Inject a small config script when features are toggled. + if cfg.DisableSignup { + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(indexBytes, marker); i >= 0 { + inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n") + nb := make([]byte, 0, len(indexBytes)+len(inject)) + nb = append(nb, indexBytes[:i]...) + nb = append(nb, inject...) + nb = append(nb, indexBytes[i:]...) + indexBytes = nb + } + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") if p == "" { diff --git a/internal/web/app.js b/internal/web/app.js @@ -11,6 +11,7 @@ createApp({ busySearch: false, results: [], view: 'login', // 'login' | 'signup' | 'self' | 'public' + disableSignup: !!(window.MARKY_CFG && window.MARKY_CFG.disableSignup), me: null, viewUser: '', auth: { username: '', password: '' }, diff --git a/internal/web/index.html b/internal/web/index.html @@ -57,11 +57,11 @@ <button :disabled="busy">{{ busy ? 'Working…' : 'Login' }}</button> </div> <p v-if="error" class="error">{{ error }}</p> - <p class="auth-links">No account? <a href="#" @click.prevent="view='signup'">Create one</a></p> + <p class="auth-links" v-if="!disableSignup">No account? <a href="#" @click.prevent="view='signup'">Create one</a></p> </form> </section> - <section v-if="view==='signup'"> + <section v-if="view==='signup' && !disableSignup"> <h2>Sign Up</h2> <form @submit.prevent="signup"> <label>Username <input v-model.trim="auth.username" autocomplete="username" required /></label>