0861f7510b
Build and Deploy / build-and-deploy (push) Failing after 13s
- Each user owns a podcast: podcast title/author/image on users table - Episodes scoped by user_id with context-based auth - Audio file upload replaces URL linking, served from /uploads/ - Podcast and per-episode images with itunes:image in RSS - RSS per-user via ?user=N, dashboard shows user-specific feed URL - Settings form for title + author + image per user - Docker multi-stage build (golang:1.25-alpine / alpine:3.21) - Removed PODSTALK_TITLE/AUTHOR env vars
184 lines
4.2 KiB
Go
184 lines
4.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"podstalk/store"
|
|
)
|
|
|
|
// Simple in-memory session store.
|
|
type SessionStore struct {
|
|
mu sync.Mutex
|
|
sessions map[string]int64 // token -> userID
|
|
}
|
|
|
|
func NewSessionStore() *SessionStore {
|
|
return &SessionStore{sessions: make(map[string]int64)}
|
|
}
|
|
|
|
func (ss *SessionStore) Create(userID int64) string {
|
|
token := make([]byte, 32)
|
|
rand.Read(token)
|
|
key := hex.EncodeToString(token)
|
|
ss.mu.Lock()
|
|
ss.sessions[key] = userID
|
|
ss.mu.Unlock()
|
|
return key
|
|
}
|
|
|
|
func (ss *SessionStore) Get(token string) (int64, bool) {
|
|
ss.mu.Lock()
|
|
defer ss.mu.Unlock()
|
|
id, ok := ss.sessions[token]
|
|
return id, ok
|
|
}
|
|
|
|
func (ss *SessionStore) Delete(token string) {
|
|
ss.mu.Lock()
|
|
delete(ss.sessions, token)
|
|
ss.mu.Unlock()
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
type AuthHandler struct {
|
|
Store *store.Store
|
|
Sessions *SessionStore
|
|
Tpl *Templates
|
|
}
|
|
|
|
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
h.Tpl.Render(w, "signup", nil)
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodPost {
|
|
email := r.FormValue("email")
|
|
password := r.FormValue("password")
|
|
|
|
if email == "" || password == "" {
|
|
h.Tpl.Render(w, "signup", map[string]string{"Error": "Email and password are required."})
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
h.Tpl.Render(w, "signup", map[string]string{"Error": "Internal error."})
|
|
return
|
|
}
|
|
|
|
userID, err := h.Store.CreateUser(email, string(hash))
|
|
if err != nil {
|
|
h.Tpl.Render(w, "signup", map[string]string{"Error": "A user with that email already exists."})
|
|
return
|
|
}
|
|
|
|
token := h.Sessions.Create(userID)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
})
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
h.Tpl.Render(w, "signin", nil)
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodPost {
|
|
email := r.FormValue("email")
|
|
password := r.FormValue("password")
|
|
|
|
user, err := h.Store.UserByEmail(email)
|
|
if err != nil {
|
|
h.Tpl.Render(w, "signin", map[string]string{"Error": "Invalid email or password."})
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
h.Tpl.Render(w, "signin", map[string]string{"Error": "Invalid email or password."})
|
|
return
|
|
}
|
|
|
|
token := h.Sessions.Create(user.ID)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
})
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) ServeSignout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err == nil {
|
|
h.Sessions.Delete(cookie.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
|
}
|
|
type contextKey string
|
|
|
|
const userIDKey contextKey = "userID"
|
|
|
|
// UserIDFromContext extracts the authenticated user's ID from the request context.
|
|
func UserIDFromContext(r *http.Request) int64 {
|
|
id, _ := r.Context().Value(userIDKey).(int64)
|
|
return id
|
|
}
|
|
|
|
type AuthMiddleware struct {
|
|
Store *store.Store
|
|
Sessions *SessionStore
|
|
}
|
|
|
|
func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
|
return
|
|
}
|
|
userID, ok := am.Sessions.Get(cookie.Value)
|
|
if !ok {
|
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), userIDKey, userID)
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
// RedirectIfAuthed sends authenticated users to / instead of showing auth pages.
|
|
func (am *AuthMiddleware) RedirectIfAuthed(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err == nil {
|
|
if _, ok := am.Sessions.Get(cookie.Value); ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|