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
167 lines
4.1 KiB
Go
167 lines
4.1 KiB
Go
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
|
|
}
|