Initial scaffold: Go backend, React frontend, Docker Compose

- Go backend: config, db (PostgreSQL migrations), auth (argon2id + sessions),
  middleware (cookie sessions, RequireAuth), chi router, cmd/server
- React frontend: Vite + React 18 + TS + Tailwind + Gruvbox palette + terminal CSS
- Docker: compose.yml (Postgres, Valkey, minio, LiveKit, coturn, Caddy),
  livekit.yaml, Caddyfile, Dockerfile (multi-stage Go+Node+Alpine)
- .env.example, .gitignore, README.md
This commit is contained in:
2026-06-26 14:41:10 -04:00
commit e499ce884d
41 changed files with 4451 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
package auth
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type Handler struct {
db *sql.DB
cfg *config.Config
sessions *SessionStore
}
func NewHandler(db *sql.DB, cfg *config.Config) *Handler {
return &Handler{
db: db,
cfg: cfg,
sessions: NewSessionStore(db, cfg),
}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/register", h.Register)
r.Post("/login/password", h.Login)
r.Post("/logout", h.Logout)
r.Get("/me", h.Me)
}
type registerRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Email == "" || req.Username == "" || req.Password == "" {
http.Error(w, `{"error":"missing fields"}`, http.StatusBadRequest)
return
}
hash, err := HashPassword(req.Password)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
var userID string
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id
`, req.Username, req.Email, hash).Scan(&userID)
if err != nil {
http.Error(w, `{"error":"user exists"}`, http.StatusConflict)
return
}
token, err := h.sessions.Create(r.Context(), userID)
if err != nil {
http.Error(w, `{"error":"session error"}`, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: h.cfg.Session.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(h.cfg.Session.Duration.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": userID})
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var userID, hash string
err := h.db.QueryRowContext(r.Context(), `
SELECT id, password_hash FROM users WHERE email = $1
`, req.Email).Scan(&userID, &hash)
if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
ok, err := VerifyPassword(req.Password, hash)
if err != nil || !ok {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
token, err := h.sessions.Create(r.Context(), userID)
if err != nil {
http.Error(w, `{"error":"session error"}`, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: h.cfg.Session.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(h.cfg.Session.Duration.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": userID})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(h.cfg.Session.CookieName)
if err == nil {
h.sessions.Delete(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: h.cfg.Session.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
// Placeholder; requires session middleware integration
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": ""})
}
var _ = uuid.New()
var _ = errors.New("placeholder")
+72
View File
@@ -0,0 +1,72 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"golang.org/x/crypto/argon2"
)
type passwordConfig struct {
time uint32
memory uint32
threads uint8
keyLen uint32
saltLen uint32
}
var defaultParams = passwordConfig{
time: 3,
memory: 64 * 1024,
threads: 4,
keyLen: 32,
saltLen: 16,
}
// HashPassword returns an Argon2id encoded string.
func HashPassword(password string) (string, error) {
salt := make([]byte, defaultParams.saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, defaultParams.time, defaultParams.memory, defaultParams.threads, defaultParams.keyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, defaultParams.memory, defaultParams.time, defaultParams.threads, b64Salt, b64Hash)
return encodedHash, nil
}
// VerifyPassword compares a password against an encoded Argon2id hash.
func VerifyPassword(password, encodedHash string) (bool, error) {
var version int
var memory, time uint32
var threads uint8
var salt, hash []byte
_, err := fmt.Sscanf(encodedHash, "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", &version, &memory, &time, &threads, &salt, &hash)
if err != nil {
return false, fmt.Errorf("parse hash: %w", err)
}
decodedSalt, err := base64.RawStdEncoding.DecodeString(string(salt))
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
decodedHash, err := base64.RawStdEncoding.DecodeString(string(hash))
if err != nil {
return false, fmt.Errorf("decode hash: %w", err)
}
computed := argon2.IDKey([]byte(password), decodedSalt, time, memory, threads, uint32(len(decodedHash)))
if subtle.ConstantTimeCompare(computed, decodedHash) == 1 {
return true, nil
}
return false, nil
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
)
type SessionStore struct {
db *sql.DB
cfg *config.Config
}
func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
return &SessionStore{db: db, cfg: cfg}
}
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
token := hex.EncodeToString(b)
_, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (user_id, token, expires_at)
VALUES ($1, $2, $3)
`, userID, token, time.Now().Add(s.cfg.Session.Duration))
if err != nil {
return "", fmt.Errorf("insert session: %w", err)
}
return token, nil
}
func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (string, error) {
var userID string
err := s.db.QueryRowContext(ctx, `
SELECT user_id FROM sessions
WHERE token = $1 AND expires_at > NOW()
`, token).Scan(&userID)
if err != nil {
return "", err
}
return userID, nil
}
func (s *SessionStore) Delete(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token)
return err
}