Initial lookbook implementation

Pinterest-style visual bookmarking app with:
- URL metadata extraction (OG/Twitter meta, oEmbed fallback)
- Image caching in Postgres with 480px thumbnails
- Multi-tag filtering with Ctrl/Cmd for OR mode
- Fuzzy tag suggestions and inline tag editing
- Browser console auth() with first-use password setup
- Brutalist UI with Commit Mono font and Pico CSS
- Light/dark mode via browser preference
This commit is contained in:
soup 2026-01-16 21:14:23 -05:00
commit fc625fb9cf
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
486 changed files with 195373 additions and 0 deletions

167
internal/handlers/auth.go Normal file
View file

@ -0,0 +1,167 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
"git.soup.land/soup/lookbook/internal/data/auth"
"git.soup.land/soup/lookbook/internal/data/session"
)
const sessionCookieName = "lookbook_session"
const sessionDuration = 30 * 24 * time.Hour
type authRequest struct {
Password string `json:"password"`
}
type authStatus struct {
Authenticated bool `json:"authenticated"`
HasPassword bool `json:"has_password"`
}
func HandlePostAuthLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return nil
}
if req.Password == "" {
http.Error(w, "Password required", http.StatusBadRequest)
return nil
}
existing, err := auth.QGet(r.Context(), rc.DB)
if err != nil {
return fmt.Errorf("get auth: %w", err)
}
if existing == nil {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if _, err := auth.QCreate(r.Context(), rc.DB, hash); err != nil {
return fmt.Errorf("create password: %w", err)
}
} else {
if err := bcrypt.CompareHashAndPassword(existing.PasswordHash, []byte(req.Password)); err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return nil
}
}
token, err := newToken(32)
if err != nil {
return fmt.Errorf("token: %w", err)
}
expiresAt := time.Now().Add(sessionDuration)
if _, err := session.QCreate(r.Context(), rc.DB, token, expiresAt); err != nil {
return fmt.Errorf("create session: %w", err)
}
setSessionCookie(w, r, token, expiresAt)
w.WriteHeader(http.StatusNoContent)
return nil
}
func HandlePostAuthLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
cookie, err := r.Cookie(sessionCookieName)
if err == nil {
_ = session.QDelete(r.Context(), rc.DB, cookie.Value)
}
clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
return nil
}
func HandleGetAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
status := authStatus{}
existing, err := auth.QGet(r.Context(), rc.DB)
if err != nil {
return fmt.Errorf("get auth: %w", err)
}
status.HasPassword = existing != nil
authed, err := isAuthenticated(r, rc)
if err != nil {
return fmt.Errorf("auth status: %w", err)
}
status.Authenticated = authed
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(status)
}
func isAuthenticated(r *http.Request, rc *RequestContext) (bool, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return false, nil
}
row, err := session.QFindByToken(r.Context(), rc.DB, cookie.Value)
if err != nil {
return false, err
}
if row == nil {
return false, nil
}
if time.Now().After(row.ExpiresAt) {
_ = session.QDelete(r.Context(), rc.DB, cookie.Value)
return false, nil
}
return true, nil
}
func requireAuth(w http.ResponseWriter, r *http.Request, rc *RequestContext) bool {
authed, err := isAuthenticated(r, rc)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
if !authed {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
return true
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
Expires: expiresAt,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
func newToken(length int) (string, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}