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:
commit
fc625fb9cf
486 changed files with 195373 additions and 0 deletions
167
internal/handlers/auth.go
Normal file
167
internal/handlers/auth.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue