Pinned 2026 toolchains (Go 1.26, Rust 1.98/edition 2024, Python 3.14 + uv, Node 24, Zig 0.16, NixOS 26.05), postgres 18 / mongo 8, lockfiles built from, non-root runtimes, .dockerignore, per-project LICENSE, READMEs with the git.devai.io clone line, checkout@v7 CI. Security fixes in the legacy Rust APIs (any-password login, self-assigned admin, hard-coded JWT secret), JWT alg/exp/sub enforcement across the blog series, safe markdown links in the frontends, and many smaller bugs — every project was built, run and exercised end to end. Adds scripts/publish.sh + a CI publish job that splits every folder into its own repo at git.devai.io/templates/<folder>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
134 lines
3.8 KiB
Go
134 lines
3.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type ctxKey struct{}
|
|
|
|
// userID returns the authenticated user id ("sub" claim, an ObjectID hex
|
|
// string) set by requireAuth.
|
|
func userID(r *http.Request) string {
|
|
v, _ := r.Context().Value(ctxKey{}).(string)
|
|
return v
|
|
}
|
|
|
|
func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decode(w, r, &in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid json body")
|
|
return
|
|
}
|
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
|
if !strings.Contains(in.Email, "@") {
|
|
writeErr(w, http.StatusBadRequest, "valid email required")
|
|
return
|
|
}
|
|
if len(in.Password) < 8 {
|
|
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
if err != nil { // bcrypt rejects passwords longer than 72 bytes
|
|
writeErr(w, http.StatusBadRequest, "password too long")
|
|
return
|
|
}
|
|
res, err := a.users.InsertOne(r.Context(), bson.M{
|
|
"email": in.Email,
|
|
"password_hash": string(hash),
|
|
"created_at": mongoNow(),
|
|
})
|
|
if isDuplicate(err) {
|
|
writeErr(w, http.StatusConflict, "email already registered")
|
|
return
|
|
}
|
|
if err != nil {
|
|
internalErr(w, err)
|
|
return
|
|
}
|
|
id := res.InsertedID.(bson.ObjectID)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"id": id.Hex(), "email": in.Email})
|
|
}
|
|
|
|
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decode(w, r, &in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid json body")
|
|
return
|
|
}
|
|
var u struct {
|
|
ID bson.ObjectID `bson:"_id"`
|
|
PasswordHash string `bson:"password_hash"`
|
|
}
|
|
err := a.users.FindOne(r.Context(),
|
|
bson.M{"email": strings.ToLower(strings.TrimSpace(in.Email))}).Decode(&u)
|
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
|
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
|
return
|
|
}
|
|
if err != nil {
|
|
internalErr(w, err)
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
|
|
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
|
return
|
|
}
|
|
token, err := a.issueToken(u.ID.Hex())
|
|
if err != nil {
|
|
internalErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"token": token})
|
|
}
|
|
|
|
// issueToken signs an HS256 JWT with sub = user id, valid for 7 days.
|
|
func (a *app) issueToken(sub string) (string, error) {
|
|
now := time.Now()
|
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": sub,
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(7 * 24 * time.Hour).Unix(),
|
|
}).SignedString(a.secret)
|
|
}
|
|
|
|
// requireAuth guards a handler: it validates the Bearer token and makes the
|
|
// user id available through userID(r).
|
|
func (a *app) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !ok || raw == "" {
|
|
writeErr(w, http.StatusUnauthorized, "missing bearer token")
|
|
return
|
|
}
|
|
token, err := jwt.Parse(raw,
|
|
func(*jwt.Token) (any, error) { return a.secret, nil },
|
|
jwt.WithValidMethods([]string{"HS256"}),
|
|
jwt.WithExpirationRequired())
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
sub, err := token.Claims.GetSubject()
|
|
if err != nil || sub == "" {
|
|
writeErr(w, http.StatusUnauthorized, "invalid token subject")
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, sub)))
|
|
}
|
|
}
|