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
}
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Host string
Port string
Database DatabaseConfig
Valkey ValkeyConfig
Session SessionConfig
}
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Database string
}
type ValkeyConfig struct {
URL string
}
type SessionConfig struct {
CookieName string
Duration time.Duration
}
func Load() *Config {
return &Config{
Host: getEnv("DUMPSTER_HOST", "localhost"),
Port: getEnv("DUMPSTER_PORT", "8080"),
Database: DatabaseConfig{
Host: getEnv("POSTGRES_HOST", "localhost"),
Port: getEnv("POSTGRES_PORT", "5432"),
User: getEnv("POSTGRES_USER", "dumpster"),
Password: getEnv("POSTGRES_PASSWORD", "dumpster"),
Database: getEnv("POSTGRES_DB", "dumpster"),
},
Valkey: ValkeyConfig{
URL: getEnv("VALKEY_URL", "redis://localhost:***@example.com"),
},
Session: SessionConfig{
CookieName: getEnv("DUMPSTER_SESSION_COOKIE", "dumpster_session"),
Duration: time.Duration(getInt("DUMPSTER_SESSION_DAYS", 30)) * 24 * time.Hour,
},
}
}
func (c *Config) DatabaseDSN() string {
return "postgres://" + c.Database.User + ":***@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=disable"
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return strings.TrimSpace(v)
}
return fallback
}
func getBool(key string, fallback bool) bool {
s := strings.ToLower(getEnv(key, ""))
if s == "" {
return fallback
}
return s == "true" || s == "1" || s == "yes"
}
func getInt(key string, fallback int) int {
s := getEnv(key, "")
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return n
}
+92
View File
@@ -0,0 +1,92 @@
package db
import (
"context"
"database/sql"
"fmt"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
_ "github.com/jackc/pgx/v5/stdlib"
)
type DB struct {
*sql.DB
}
func New(cfg *config.Config) (*DB, error) {
db, err := sql.Open("pgx", cfg.DatabaseDSN())
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{db}, nil
}
func (d *DB) RunMigrations() error {
_, err := d.ExecContext(context.Background(), migrationsSQL)
return err
}
const migrationsSQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
display_name VARCHAR(32),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
avatar TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'offline',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS servers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
icon TEXT,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'text',
category VARCHAR(100) NOT NULL DEFAULT 'general',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, name)
);
CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
`
+52
View File
@@ -0,0 +1,52 @@
package middleware
import (
"context"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
)
type contextKey string
const UserContextKey contextKey = "user_id"
type SessionStore interface {
GetUserIDByToken(ctx context.Context, token string) (string, error)
}
func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cfg.Session.CookieName)
if err != nil {
next.ServeHTTP(w, r)
return
}
userID, err := store.GetUserIDByToken(r.Context(), cookie.Value)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), UserContextKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func UserIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(UserContextKey).(string)
return id, ok
}
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := UserIDFromContext(r.Context()); !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}