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:
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
`
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user