561 lines
21 KiB
Go
561 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
_ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/audit"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/notification"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/readstate"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/servergroup"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
|
"github.com/go-chi/chi/v5"
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
httpSwagger "github.com/swaggo/http-swagger"
|
|
)
|
|
|
|
// @title Dumpster API
|
|
// @version 1.0
|
|
// @description A chaotic, self-hosted Discord-like platform API
|
|
// @host localhost:8080
|
|
// @BasePath /api/v1
|
|
// @schemes http https
|
|
// @securityDefinitions.apikey SessionAuth
|
|
// @in cookie
|
|
// @name dumpster_session
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
|
|
|
cfg := config.Load()
|
|
|
|
database, err := db.New(cfg)
|
|
if err != nil {
|
|
logger.Error("failed to connect to database", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer database.Close()
|
|
|
|
if err := database.RunMigrations(); err != nil {
|
|
logger.Error("failed to run migrations", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Session store for middleware
|
|
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
|
|
|
// WebSocket origin allowlist
|
|
wsOrigins := []string{"https://" + cfg.Host, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}
|
|
if cfg.Host == "localhost" {
|
|
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
|
}
|
|
gateway.SetAllowedOrigins(wsOrigins)
|
|
|
|
// WebSocket hub
|
|
hub := gateway.NewHub(database.DB, logger)
|
|
go hub.Run()
|
|
|
|
// Built-in bot runner
|
|
botRunner := bot.NewRunner(database.DB, hub, logger)
|
|
botRunner.Register("steamfree", bot.SteamFreeBot)
|
|
botRunner.Register("confess", bot.ConfessBot)
|
|
botRunner.Register("leaderboard", bot.LeaderboardBot)
|
|
go botRunner.StartAll()
|
|
|
|
// Giphy client (nil if no API key)
|
|
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
|
|
|
// Upload handler (nil if no MinIO config)
|
|
uploadHandler, err := upload.NewHandler(cfg)
|
|
if err != nil {
|
|
logger.Warn("upload handler not available", "error", err)
|
|
}
|
|
|
|
// Voice client (LiveKit)
|
|
voiceClient := voice.NewClient(cfg.LiveKit.APIKey, cfg.LiveKit.Secret, cfg.LiveKit.URL)
|
|
if voiceClient == nil {
|
|
logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)")
|
|
}
|
|
|
|
// Push notification handler (nil if no VAPID keys)
|
|
pushHandler := push.NewHandler(database.DB, cfg.WebPush.PublicKey, cfg.WebPush.PrivateKey, cfg.WebPush.Subject, logger)
|
|
if cfg.WebPush.PublicKey == "" {
|
|
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
|
}
|
|
|
|
// Email mailer
|
|
mailer := email.NewMailer(cfg.SMTP)
|
|
|
|
// Auth handler
|
|
authHandler := auth.NewHandler(database.DB, cfg, hub, mailer)
|
|
|
|
// Permissions checker
|
|
permissionsChecker := permissions.NewChecker(database.DB)
|
|
memberHandler := server.NewMemberHandler(database.DB)
|
|
|
|
r := chi.NewRouter()
|
|
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
|
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
r.Use(chimw.Logger)
|
|
r.Use(chimw.Recoverer)
|
|
r.Use(chimw.RequestID)
|
|
r.Use(middleware.SecurityHeaders)
|
|
|
|
// Health check
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
// WebSocket endpoint
|
|
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
|
})
|
|
|
|
// Bot WebSocket endpoint (auth via ?token= query param)
|
|
r.Get("/ws/bot", func(w http.ResponseWriter, r *http.Request) {
|
|
gateway.ServeBotWS(database.DB, hub, logger, w, r)
|
|
})
|
|
|
|
// API routes
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Auth (public: register, login, logout) with strict rate limiting
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
|
authHandler.RegisterPublicRoutes(r)
|
|
})
|
|
|
|
// Protected routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Session(sessionStore, cfg))
|
|
r.Use(middleware.RequireAuth)
|
|
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ",")))
|
|
|
|
// Auth (protected: me, update profile)
|
|
authHandler.RegisterProtectedRoutes(r)
|
|
|
|
// Servers
|
|
r.Route("/servers", func(r chi.Router) {
|
|
serverHandler := server.NewHandler(database.DB)
|
|
r.Post("/", serverHandler.Create)
|
|
r.Get("/", serverHandler.List)
|
|
|
|
modHandler := moderation.NewHandler(database.DB, hub)
|
|
auditLogger := audit.NewLogger(database.DB)
|
|
auditHandler := audit.NewHandler(database.DB)
|
|
|
|
r.Route("/{serverID}", func(r chi.Router) {
|
|
r.Get("/", serverHandler.Get)
|
|
r.Patch("/", serverHandler.Update)
|
|
r.Delete("/", serverHandler.Delete)
|
|
|
|
// Audit log
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.MANAGE_SERVER)).Get("/audit-log", auditHandler.List)
|
|
|
|
// Server members
|
|
memberHandler.RegisterRoutes(r)
|
|
|
|
// Moderation
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
|
modHandler.Kick(w, r)
|
|
serverID := chi.URLParam(r, "serverID")
|
|
targetID := chi.URLParam(r, "userID")
|
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
|
_ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionKick, "user", targetID, "", nil)
|
|
})
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", func(w http.ResponseWriter, r *http.Request) {
|
|
modHandler.Ban(w, r)
|
|
})
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans)
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
|
modHandler.Unban(w, r)
|
|
serverID := chi.URLParam(r, "serverID")
|
|
targetID := chi.URLParam(r, "userID")
|
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
|
_ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnban, "user", targetID, "", nil)
|
|
})
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", func(w http.ResponseWriter, r *http.Request) {
|
|
modHandler.Mute(w, r)
|
|
})
|
|
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
|
modHandler.Unmute(w, r)
|
|
serverID := chi.URLParam(r, "serverID")
|
|
targetID := chi.URLParam(r, "userID")
|
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
|
_ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnmute, "user", targetID, "", nil)
|
|
})
|
|
|
|
// Channels (nested under servers)
|
|
r.Route("/channels", func(r chi.Router) {
|
|
channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r)
|
|
})
|
|
|
|
// Server groups (sub-server sections)
|
|
r.Route("/groups", func(r chi.Router) {
|
|
servergroup.NewHandler(database.DB).RegisterRoutes(r)
|
|
})
|
|
|
|
// Availability
|
|
r.Get("/availability", authHandler.GetServerAvailability)
|
|
})
|
|
})
|
|
|
|
// Direct messages
|
|
dmHandler := dm.NewHandler(database.DB, hub, pushHandler, logger)
|
|
r.Route("/conversations", func(r chi.Router) {
|
|
dmHandler.RegisterRoutes(r)
|
|
})
|
|
|
|
// Roles and member-role assignment
|
|
roleHandler := server.NewRoleHandler(database.DB)
|
|
roleHandler.RegisterRoleRoutes(r)
|
|
|
|
// Messages (under channels)
|
|
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
|
msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker)
|
|
msgHandler.SetConfessHandler(botRunner)
|
|
msgHandler.RegisterRoutes(r)
|
|
})
|
|
|
|
// Polls
|
|
pollHandler := message.NewPollHandler(database.DB, hub, permissionsChecker)
|
|
r.Route("/polls", func(r chi.Router) {
|
|
pollHandler.RegisterRoutes(r)
|
|
})
|
|
|
|
// Feature requests
|
|
frHandler := message.NewFeatureRequestHandler(database.DB)
|
|
r.Route("/servers/{serverID}/feature-requests", func(r chi.Router) {
|
|
frHandler.RegisterRoutes(r)
|
|
})
|
|
|
|
// Per-channel notification settings
|
|
r.Route("/channels/{channelID}/notifications", func(r chi.Router) {
|
|
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
|
})
|
|
|
|
// Calendar events
|
|
calHandler := channel.NewHandler(database.DB, permissionsChecker)
|
|
r.Route("/channels/{channelID}/events", func(r chi.Router) {
|
|
r.Get("/", calHandler.ListEvents)
|
|
r.Post("/", calHandler.CreateEvent)
|
|
})
|
|
|
|
// Threads (forum posts)
|
|
threadHandler := channel.NewHandler(database.DB, permissionsChecker)
|
|
r.Route("/channels/{channelID}/threads", func(r chi.Router) {
|
|
r.Get("/", threadHandler.ListThreads)
|
|
r.Post("/", threadHandler.CreateThread)
|
|
})
|
|
r.Patch("/threads/{threadID}", threadHandler.UpdateThread)
|
|
|
|
// Forum tags
|
|
r.Get("/channels/{channelID}/forum-tags", threadHandler.ListForumTags)
|
|
r.Post("/channels/{channelID}/forum-tags", threadHandler.CreateForumTag)
|
|
r.Delete("/forum-tags/{tagID}", threadHandler.DeleteForumTag)
|
|
|
|
// Push notifications
|
|
pushHandler.RegisterRoutes(r)
|
|
|
|
// Read receipts
|
|
rsHandler := readstate.NewHandler(database.DB, logger)
|
|
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
|
r.Get("/users/me/read-states", rsHandler.GetAll)
|
|
|
|
// Giphy search
|
|
if giphyClient != nil {
|
|
r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("q")
|
|
if query == "" {
|
|
http.Error(w, `{"error":"missing query"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
gifs, err := giphyClient.Search(query, 20)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"search failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(gifs)
|
|
})
|
|
r.Get("/gifs/trending", func(w http.ResponseWriter, r *http.Request) {
|
|
gifs, err := giphyClient.GetTrending(20)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"trending failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(gifs)
|
|
})
|
|
r.Get("/gifs/proxy", func(w http.ResponseWriter, r *http.Request) {
|
|
targetURL := r.URL.Query().Get("url")
|
|
if targetURL == "" {
|
|
http.Error(w, `{"error":"missing url"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify it's a Giphy domain to prevent general proxy abuse
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil || parsed.Scheme != "https" || !strings.HasSuffix(parsed.Host, ".giphy.com") {
|
|
http.Error(w, `{"error":"invalid proxy target"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
resp, err := http.Get(targetURL)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to fetch image"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Stream response
|
|
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
|
w.Header().Set("Content-Length", resp.Header.Get("Content-Length"))
|
|
w.Header().Set("Cache-Control", "public, max-age=604800") // Cache for 7 days
|
|
w.WriteHeader(resp.StatusCode)
|
|
io.Copy(w, resp.Body)
|
|
})
|
|
}
|
|
|
|
// File upload
|
|
if uploadHandler != nil {
|
|
r.Post("/upload", uploadHandler.Upload)
|
|
}
|
|
|
|
// Voice
|
|
if voiceClient != nil {
|
|
r.Route("/voice", func(r chi.Router) {
|
|
voice.NewHandler(database.DB, voiceClient, hub).RegisterRoutes(r)
|
|
})
|
|
}
|
|
|
|
// Reactions
|
|
r.Route("/messages/{messageID}/reactions", func(r chi.Router) {
|
|
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
|
})
|
|
|
|
// Bots + slash commands
|
|
r.Route("/bots", func(r chi.Router) {
|
|
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
|
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
|
})
|
|
|
|
// Webhooks (protected: create/list/delete)
|
|
webhookHandler := webhook.NewHandler(database.DB, hub)
|
|
r.Route("/channels/{channelID}/webhooks", func(r chi.Router) {
|
|
webhookHandler.RegisterRoutes(r)
|
|
})
|
|
r.Delete("/webhooks/{webhookID}", webhookHandler.Delete)
|
|
|
|
// Invites (rate limited to prevent brute-force join)
|
|
inviteHandler := invite.NewHandler(database.DB)
|
|
r.Post("/servers/{serverID}/invites", inviteHandler.Create)
|
|
r.Get("/invites/{code}", inviteHandler.Get)
|
|
r.Route("/invites/{code}/join", func(r chi.Router) {
|
|
r.Use(middleware.RateLimit(2, 5))
|
|
r.Post("/", inviteHandler.Join)
|
|
})
|
|
})
|
|
})
|
|
|
|
// File serving (public, for viewing uploaded files)
|
|
if uploadHandler != nil {
|
|
r.Get("/files/*", uploadHandler.Serve)
|
|
|
|
// Legacy redirect: old uploads returned /{bucket}/objectName
|
|
r.Get("/dumpster-files/*", func(w http.ResponseWriter, r *http.Request) {
|
|
objectName := strings.TrimPrefix(r.URL.Path, "/dumpster-files/")
|
|
http.Redirect(w, r, "/files/"+objectName, http.StatusMovedPermanently)
|
|
})
|
|
}
|
|
|
|
// Public webhook execution (no auth required)
|
|
r.Post("/webhooks/{webhookID}/{token}", func(w http.ResponseWriter, r *http.Request) {
|
|
webhook.NewHandler(database.DB, hub).Execute(w, r)
|
|
})
|
|
|
|
// Swagger UI (only accessible from localhost to avoid exposing API docs)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
host := r.Host
|
|
if host == "" {
|
|
host = r.Header.Get("Host")
|
|
}
|
|
if host != "localhost:"+cfg.Port && host != "127.0.0.1:"+cfg.Port && host != "[::1]:"+cfg.Port {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
r.Get("/docs/*", httpSwagger.Handler(
|
|
httpSwagger.URL("/docs/swagger.json"),
|
|
))
|
|
})
|
|
|
|
// Privacy policy page (served before SPA catch-all)
|
|
r.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write([]byte(privacyPage))
|
|
})
|
|
|
|
// Static file serving for production (SPA)
|
|
staticDir := "web/dist"
|
|
if _, err := os.Stat(staticDir); err == nil {
|
|
fileServer := http.FileServer(http.Dir(staticDir))
|
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
|
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
|
path := staticDir + r.URL.Path
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
http.ServeFile(w, r, staticDir+"/index.html")
|
|
return
|
|
}
|
|
fileServer.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
|
logger.Info("starting server", "addr", addr)
|
|
if err := http.ListenAndServe(addr, r); err != nil {
|
|
logger.Error("server error", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
const privacyPage = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Privacy Policy — dumpsterChat</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
background: #1d2021; color: #ebdbb2; line-height: 1.7; padding: 2rem 1rem;
|
|
}
|
|
main { max-width: 720px; margin: 0 auto; }
|
|
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; color: #fabd2f; }
|
|
.subtitle { color: #a89984; font-size: 0.85rem; margin-bottom: 2rem; }
|
|
h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; color: #83a598; }
|
|
p, li { margin-bottom: 0.6rem; }
|
|
ul { padding-left: 1.25rem; }
|
|
li { margin-bottom: 0.3rem; }
|
|
a { color: #8ec07c; }
|
|
.footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #3c3836; font-size: 0.8rem; color: #928374; }
|
|
.update { color: #928374; font-size: 0.8rem; margin-top: 1.5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Privacy Policy</h1>
|
|
<p class="subtitle"><strong>dumpsterChat</strong> — Last updated: July 17, 2026</p>
|
|
|
|
<h2>Overview</h2>
|
|
<p>dumpsterChat is a self-hosted messaging platform. This privacy policy describes how your data is handled when you use the app. Because dumpsterChat is <strong>self-hosted</strong>, your data is stored on the server instance you connect to, which is operated by the server owner — not by us.</p>
|
|
|
|
<h2>Data We Collect</h2>
|
|
<p>When you use dumpsterChat, the following data is stored on the server:</p>
|
|
<ul>
|
|
<li><strong>Account information:</strong> username, email address, avatar, and password hash (Argon2id, not reversible).</li>
|
|
<li><strong>Messages and content:</strong> text messages, reactions, uploaded files, voice activity metadata, and poll votes.</li>
|
|
<li><strong>Session data:</strong> login sessions stored in encrypted cookies.</li>
|
|
</ul>
|
|
|
|
<h2>How We Use Your Data</h2>
|
|
<p>Your data is used solely to operate the chat platform:</p>
|
|
<ul>
|
|
<li>Deliver messages and notifications to the intended recipients.</li>
|
|
<li>Sync read states and presence (online/offline) across your devices.</li>
|
|
<li>Provide moderation tools (kicks, bans, mutes) per server rules.</li>
|
|
</ul>
|
|
|
|
<h2>No Third-Party Analytics</h2>
|
|
<p>dumpsterChat does <strong>not</strong> include any analytics SDKs, tracking pixels, or telemetry. No usage data is sent to us or to any third party for advertising, profiling, or analytics purposes.</p>
|
|
|
|
<h2>Third-Party Integrations</h2>
|
|
<p>If enabled by the server owner, optional integrations may be used:</p>
|
|
<ul>
|
|
<li><strong>Giphy:</strong> GIF search queries are proxied through the server to Giphy's API. No user data is shared with Giphy.</li>
|
|
<li><strong>LiveKit:</strong> Voice and video calls use a self-hosted LiveKit server. Media streams are processed in real time and are not recorded or stored by default.</li>
|
|
</ul>
|
|
<p>These integrations are optional and controlled entirely by the server owner.</p>
|
|
|
|
<h2>Data Retention</h2>
|
|
<p>Data is retained for as long as the server owner maintains the database. You can delete your messages or account at any time through the app. Server owners may also set message retention limits. Uploaded files persist until explicitly removed.</p>
|
|
|
|
<h2>Your Rights</h2>
|
|
<p>Depending on your jurisdiction, you may have the right to:</p>
|
|
<ul>
|
|
<li>Request a copy of your stored data.</li>
|
|
<li>Delete your account and associated data.</li>
|
|
<li>Correct inaccurate personal information.</li>
|
|
</ul>
|
|
<p>To exercise these rights, contact the operator of the dumpsterChat instance you use, or use the account management tools available within the app.</p>
|
|
|
|
<h2>Account Deletion Requests</h2>
|
|
<p>To request deletion of your account and all associated data:</p>
|
|
<ul>
|
|
<li>Use the <strong>Delete Account</strong> option in your account settings within the app.</li>
|
|
<li>Or email <a href="mailto:account-deletion@dustin.coffee">account-deletion@dustin.coffee</a> from the email address associated with your account.</li>
|
|
</ul>
|
|
<p>We will process your request within 30 days. Deletion removes your account, messages, uploaded files, and all personal data from the server.</p>
|
|
|
|
<h2>Security</h2>
|
|
<p>We take reasonable measures to protect your data:</p>
|
|
<ul>
|
|
<li>Passwords are hashed with Argon2id.</li>
|
|
<li>Session tokens use httpOnly cookies.</li>
|
|
<li>All communications are encrypted over HTTPS and WSS where available.</li>
|
|
</ul>
|
|
|
|
<h2>Children's Privacy</h2>
|
|
<p>dumpsterChat is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
|
|
|
<h2>Changes to This Policy</h2>
|
|
<p>We may update this privacy policy from time to time. Changes will be posted at this URL. Continued use of the app after changes constitutes acceptance of the updated policy.</p>
|
|
|
|
<h2>Contact</h2>
|
|
<p>If you have questions about this privacy policy, contact the operator of the dumpsterChat instance you use, or open an issue on our project repository.</p>
|
|
|
|
<div class="update">This privacy policy applies to the dumpsterChat mobile app and web app. The specific data practices of each instance may vary based on the server operator's configuration.</div>
|
|
</main>
|
|
</body>
|
|
</html>`
|