Compare commits
5 Commits
5f2b0aea8b
...
v0.3.11
| Author | SHA1 | Date | |
|---|---|---|---|
| 738b9b17ff | |||
| 44370f41e5 | |||
| 1d6c9bdfe6 | |||
| 57aec2c6b3 | |||
| d4ca5f576e |
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
web/dist
|
||||||
|
certs
|
||||||
|
*.zip
|
||||||
|
dumpster-server
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -8,8 +8,31 @@ export NVM_DIR="$HOME/.nvm"
|
|||||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
nvm use 22
|
nvm use 22
|
||||||
|
|
||||||
|
# Snapshot the current binary before pulling
|
||||||
|
if [ -f dumpster-server ]; then
|
||||||
|
cp dumpster-server dumpster-server.bak
|
||||||
|
echo "Backed up existing binary to dumpster-server.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
git pull
|
git pull
|
||||||
make build
|
make build
|
||||||
systemctl restart dumpster
|
|
||||||
|
|
||||||
|
# Health check after restart — roll back on failure
|
||||||
|
systemctl restart dumpster
|
||||||
|
echo "Waiting for app to become healthy..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
|
||||||
|
echo "Server is listening — deploy complete"
|
||||||
|
rm -f dumpster-server.bak
|
||||||
echo "Deploy complete — $(date)"
|
echo "Deploy complete — $(date)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Health check failed after 30s — rolling back..."
|
||||||
|
systemctl stop dumpster
|
||||||
|
cp dumpster-server.bak dumpster-server
|
||||||
|
systemctl start dumpster
|
||||||
|
echo "Rolled back to previous binary — $(date)"
|
||||||
|
exit 1
|
||||||
|
|||||||
+3
-1
@@ -16,8 +16,10 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk --no-cache add ca-certificates
|
RUN apk --no-cache add ca-certificates
|
||||||
|
RUN adduser -D -g '' appuser
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
||||||
COPY --from=web-builder /app/dist /srv/web
|
COPY --from=web-builder /app/dist /srv/web
|
||||||
|
USER appuser
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["sh", "-c", "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"]
|
CMD sh -c "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"
|
||||||
|
|||||||
+22
-3
@@ -1,5 +1,9 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
backend:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -32,6 +36,9 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
valkey:
|
valkey:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -49,6 +56,8 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
valkey:
|
valkey:
|
||||||
image: valkey/valkey:8
|
image: valkey/valkey:8
|
||||||
@@ -62,6 +71,8 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
@@ -75,6 +86,8 @@ services:
|
|||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:latest
|
||||||
@@ -89,6 +102,8 @@ services:
|
|||||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
environment:
|
environment:
|
||||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
@@ -99,8 +114,8 @@ services:
|
|||||||
- "--max-port=50200"
|
- "--max-port=50200"
|
||||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||||
- "--cert=/etc/coturn/certs/turn.crt"
|
- "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
|
||||||
- "--pkey=/etc/coturn/certs/turn.key"
|
- "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
|
||||||
- "--tls-listening-port=5349"
|
- "--tls-listening-port=5349"
|
||||||
ports:
|
ports:
|
||||||
- "3478:3478/tcp"
|
- "3478:3478/tcp"
|
||||||
@@ -109,7 +124,9 @@ services:
|
|||||||
- "5349:5349/udp"
|
- "5349:5349/udp"
|
||||||
- "50101-50200:50101-50200/udp"
|
- "50101-50200:50101-50200/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./certs:/etc/coturn/certs:ro
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: caddy:2
|
||||||
@@ -124,6 +141,8 @@ services:
|
|||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
token := generateToken()
|
token := generateToken()
|
||||||
|
tokenHash := hashToken(token)
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||||
`, userID, token)
|
`, userID, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -63,7 +64,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
DELETE FROM user_tokens
|
DELETE FROM user_tokens
|
||||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
`, req.Token).Scan(&userID)
|
`, hashToken(req.Token)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -100,10 +101,11 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
token := generateToken()
|
token := generateToken()
|
||||||
|
tokenHash := hashToken(token)
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||||
`, userID, token)
|
`, userID, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -134,7 +136,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
DELETE FROM user_tokens
|
DELETE FROM user_tokens
|
||||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
`, req.Token).Scan(&userID)
|
`, hashToken(req.Token)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
|
|||||||
return &SessionStore{db: db, cfg: cfg}
|
return &SessionStore{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashToken computes a SHA-256 hash of a token string.
|
||||||
|
// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions.
|
||||||
|
func hashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", fmt.Errorf("generate token: %w", err)
|
return "", fmt.Errorf("generate token: %w", err)
|
||||||
}
|
}
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO sessions (user_id, token, expires_at)
|
INSERT INTO sessions (user_id, token, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
`, userID, token, time.Now().Add(s.cfg.Session.Duration))
|
`, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert session: %w", err)
|
return "", fmt.Errorf("insert session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -43,7 +52,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
|||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT user_id FROM sessions
|
SELECT user_id FROM sessions
|
||||||
WHERE token = $1 AND expires_at > NOW()
|
WHERE token = $1 AND expires_at > NOW()
|
||||||
`, token).Scan(&userID)
|
`, hashToken(token)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -51,6 +60,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token)
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
|
|
||||||
@@ -24,6 +25,11 @@ func New(cfg *config.Config) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("ping database: %w", err)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: prevent unbounded connection growth under burst load
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
db.SetConnMaxLifetime(15 * time.Minute)
|
||||||
|
|
||||||
return &DB{db}, nil
|
return &DB{db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
@@ -12,16 +13,21 @@ import (
|
|||||||
type ipLimiter struct {
|
type ipLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
limiters map[string]*rate.Limiter
|
limiters map[string]*rate.Limiter
|
||||||
|
lastUsed map[string]time.Time
|
||||||
rate rate.Limit
|
rate rate.Limit
|
||||||
burst int
|
burst int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||||
return &ipLimiter{
|
l := &ipLimiter{
|
||||||
limiters: make(map[string]*rate.Limiter),
|
limiters: make(map[string]*rate.Limiter),
|
||||||
|
lastUsed: make(map[string]time.Time),
|
||||||
rate: r,
|
rate: r,
|
||||||
burst: burst,
|
burst: burst,
|
||||||
}
|
}
|
||||||
|
// ponytail: evict inactive limiters every 5 minutes to prevent unbounded map growth
|
||||||
|
go l.cleanup()
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||||
@@ -33,9 +39,27 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
|||||||
lim = rate.NewLimiter(l.rate, l.burst)
|
lim = rate.NewLimiter(l.rate, l.burst)
|
||||||
l.limiters[ip] = lim
|
l.limiters[ip] = lim
|
||||||
}
|
}
|
||||||
|
l.lastUsed[ip] = time.Now()
|
||||||
return lim
|
return lim
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanup runs in a background goroutine and removes limiters that haven't
|
||||||
|
// been accessed in 10 minutes.
|
||||||
|
func (l *ipLimiter) cleanup() {
|
||||||
|
for {
|
||||||
|
time.Sleep(5 * time.Minute)
|
||||||
|
l.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for ip, last := range l.lastUsed {
|
||||||
|
if now.Sub(last) > 10*time.Minute {
|
||||||
|
delete(l.limiters, ip)
|
||||||
|
delete(l.lastUsed, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimit returns middleware that limits requests per IP.
|
// RateLimit returns middleware that limits requests per IP.
|
||||||
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
||||||
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineMarkdownToHtml(text: string): string {
|
function inlineMarkdownToHtml(text: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user