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