Initial commit: Lookbook personal collection app
Pinterest-like app for saving images, videos, quotes, and embeds. Features: - Go backend with PostgreSQL, SSR templates - Console-based admin auth (login/logout via browser console) - Item types: images, videos (ffmpeg transcoding), quotes, embeds - Media stored as BLOBs in PostgreSQL - OpenGraph metadata extraction for links - Embed detection for YouTube, Vimeo, Twitter/X - Masonry grid layout, item detail pages - Tag system with filtering - Refresh metadata endpoint with change warnings - Replace media endpoint for updating item images/videos
This commit is contained in:
commit
cdcc5b5293
45 changed files with 4634 additions and 0 deletions
141
internal/handlers/api_auth.go
Normal file
141
internal/handlers/api_auth.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"lookbook/internal/data/admin"
|
||||
"lookbook/internal/data/session"
|
||||
)
|
||||
|
||||
const sessionDuration = 30 * 24 * time.Hour // 30 days
|
||||
|
||||
type loginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
FirstTime bool `json:"firstTime,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandleLogin handles POST /api/auth/login
|
||||
// If no password is set, it sets the password. Otherwise, it verifies the password.
|
||||
func HandleLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return writeJSON(w, http.StatusBadRequest, loginResponse{Error: "invalid request"})
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
return writeJSON(w, http.StatusBadRequest, loginResponse{Error: "password required"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
adm, err := admin.QGet(ctx, rc.DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
firstTime := adm.PasswordHash == nil
|
||||
|
||||
if firstTime {
|
||||
// First login: set the password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := admin.QSetPassword(ctx, rc.DB, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword(adm.PasswordHash, []byte(req.Password)); err != nil {
|
||||
return writeJSON(w, http.StatusUnauthorized, loginResponse{Error: "invalid password"})
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
sessionID, err := generateSessionID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(sessionDuration)
|
||||
if _, err := session.QCreate(ctx, rc.DB, sessionID, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
})
|
||||
|
||||
return writeJSON(w, http.StatusOK, loginResponse{FirstTime: firstTime})
|
||||
}
|
||||
|
||||
// HandleLogout handles POST /api/auth/logout
|
||||
func HandleLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
cookie, err := r.Cookie("session_id")
|
||||
if err == nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
session.QDelete(ctx, rc.DB, cookie.Value)
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
return writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// HandleAuthStatus handles GET /api/auth/status
|
||||
func HandleAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hasPassword, err := admin.QHasPassword(ctx, rc.DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeJSON(w, http.StatusOK, map[string]any{
|
||||
"isAdmin": rc.IsAdmin,
|
||||
"passwordSet": hasPassword,
|
||||
})
|
||||
}
|
||||
|
||||
func generateSessionID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
return json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue