feat: sync durable moderation and appeals

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent e1a95c7318
commit 9f467f4be7
140 changed files with 13730 additions and 316 deletions

View file

@ -21,19 +21,20 @@ import (
)
type Config struct {
Addr string
PublicBaseURL string
AppScheme string
AppLinkBase string
WebBaseURL string
AppName string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
Addr string
PublicBaseURL string
AppScheme string
AppLinkBase string
WebBaseURL string
AppName string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
ModerationAppeals ModerationAppealResolver
// TelegramLogin is the optional OIDC/Login HTTP adapter. Public Web owns
// the listener so discovery/auth/token and public links share the exact
// externally registered origin behind one reverse proxy.
@ -73,6 +74,12 @@ type StarGiftWithdrawalResolver interface {
CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
}
type ModerationAppealResolver interface {
ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error)
Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error)
SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, bool, error)
}
func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr)
if addr == "" {
@ -157,6 +164,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
photos: cfg.Photos,
uniqueGifts: cfg.UniqueGifts,
giftWithdrawals: cfg.GiftWithdrawals,
appeals: cfg.ModerationAppeals,
publicBaseURL: cfg.PublicBaseURL,
appLinks: appLinks,
webBaseURL: cfg.WebBaseURL,
@ -173,6 +181,10 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
if cfg.ModerationAppeals != nil {
mux.HandleFunc("GET /appeal/{token}", h.moderationAppeal)
mux.HandleFunc("POST /appeal/{token}", h.moderationAppeal)
}
if cfg.TelegramLogin != nil {
mux.Handle("GET /.well-known/openid-configuration", cfg.TelegramLogin)
mux.Handle("GET /.well-known/jwks.json", cfg.TelegramLogin)
@ -197,6 +209,7 @@ type handler struct {
photos ProfilePhotoResolver
uniqueGifts UniqueStarGiftResolver
giftWithdrawals StarGiftWithdrawalResolver
appeals ModerationAppealResolver
publicBaseURL string
appLinks links.AppLinkBuilder
webBaseURL string
@ -204,6 +217,102 @@ type handler struct {
logger *zap.Logger
}
type moderationAppealPage struct {
AppName string
CaseID int64
ExpiresAt string
Submitted bool
AppealID int64
AppealStatus domain.ModerationAppealStatus
Error string
AppealText string
CanSubmit bool
}
var moderationAppealTemplate = template.Must(template.New("moderation-appeal").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer"><title>Moderation appeal · {{.AppName}}</title><style>
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:24px}.card{max-width:620px;margin:6vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}textarea{box-sizing:border-box;width:100%;min-height:160px;padding:12px;border:1px solid #ccd4dc;border-radius:10px;font:inherit}button{border:0;border-radius:10px;padding:12px 18px;background:#2481cc;color:#fff;font-weight:600;cursor:pointer}.meta{color:#53606d}.error{color:#b42318}.done{color:#18864b;font-weight:600}
</style></head><body><main class="card"><h1>Moderation appeal</h1><p class="meta">Case #{{.CaseID}} · link expires {{.ExpiresAt}}</p>
{{if .Submitted}}{{if eq .AppealStatus "granted"}}<p class="done">Your appeal #{{.AppealID}} was granted. Reversible restrictions are being removed through the audited action queue.</p>
{{else if eq .AppealStatus "rejected"}}<p class="error">Your appeal #{{.AppealID}} was not granted.</p>
{{else}}<p class="done">Your appeal #{{.AppealID}} has been submitted. It will be reviewed by a separate moderation step.</p>{{end}}
{{else}}{{if .Error}}<p class="error">{{.Error}}</p>{{end}}{{if .CanSubmit}}<p>Explain why the moderation decision should be reviewed. Do not include passwords, login codes, or payment details.</p><form method="post"><textarea name="appeal_text" maxlength="4000" required>{{.AppealText}}</textarea><p><button type="submit">Submit appeal</button></p></form>{{end}}{{end}}
</main></body></html>`))
func (h *handler) moderationAppeal(w http.ResponseWriter, r *http.Request) {
if h.appeals == nil {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
now := time.Now().UTC()
link, found, err := h.appeals.ResolveAppealLink(r.Context(), token, now)
if err != nil || !found {
http.NotFound(w, r)
return
}
page := moderationAppealPage{
AppName: h.appName, CaseID: link.CaseID,
ExpiresAt: link.ExpiresAt.UTC().Format(time.RFC3339),
Submitted: link.AppealID > 0, AppealID: link.AppealID,
CanSubmit: link.AppealID == 0,
}
if link.AppealID > 0 {
appeal, appealFound, appealErr := h.appeals.Appeal(r.Context(), link.AppealID)
if appealErr != nil || !appealFound || appeal.CaseID != link.CaseID {
h.logger.Warn("resolve linked moderation appeal status failed",
zap.Int64("case_id", link.CaseID),
zap.Int64("appeal_id", link.AppealID),
zap.Error(appealErr))
http.Error(w, "The appeal status is temporarily unavailable.", http.StatusServiceUnavailable)
return
}
page.AppealStatus = appeal.Status
}
status := http.StatusOK
if r.Method == http.MethodPost && !page.Submitted {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
page.Error = "The appeal form is too large or invalid."
status = http.StatusBadRequest
} else {
page.AppealText = strings.TrimSpace(r.FormValue("appeal_text"))
appeal, _, submitErr := h.appeals.SubmitAppealLink(
r.Context(), token, page.AppealText, now,
)
switch {
case submitErr == nil:
page.Submitted = true
page.CanSubmit = false
page.AppealID = appeal.ID
page.AppealStatus = appeal.Status
page.AppealText = ""
case errors.Is(submitErr, domain.ErrModerationCaseConflict):
page.Error = "The moderation action is still being finalized. Please retry shortly."
status = http.StatusConflict
case errors.Is(submitErr, domain.ErrModerationCaseInvalid):
page.Error = "The appeal text is invalid."
status = http.StatusBadRequest
default:
h.logger.Warn("moderation appeal submission failed",
zap.Int64("case_id", link.CaseID),
zap.Error(submitErr))
page.Error = "The appeal could not be submitted. Please retry."
status = http.StatusInternalServerError
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.WriteHeader(status)
if err := moderationAppealTemplate.Execute(w, page); err != nil {
h.logger.Warn("render moderation appeal page failed",
zap.Int64("case_id", link.CaseID), zap.Error(err))
}
}
type starGiftWithdrawalPage struct {
AppName string
Title string

View file

@ -57,6 +57,147 @@ type fakeUniqueGifts struct {
calls int
}
type fakeModerationAppeals struct {
link domain.ModerationAppealLink
found bool
resolveErr error
appeal domain.ModerationAppeal
submitErr error
submitCalls int
submitText string
}
func (f *fakeModerationAppeals) ResolveAppealLink(_ context.Context, _ string, _ time.Time) (domain.ModerationAppealLink, bool, error) {
return f.link, f.found, f.resolveErr
}
func (f *fakeModerationAppeals) Appeal(_ context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
if f.appeal.ID != appealID {
return domain.ModerationAppeal{}, false, nil
}
return f.appeal, true, nil
}
func (f *fakeModerationAppeals) SubmitAppealLink(_ context.Context, _ string, text string, _ time.Time) (domain.ModerationAppeal, bool, error) {
f.submitCalls++
f.submitText = text
return f.appeal, true, f.submitErr
}
func TestHandlerModerationAppealGetAndIdempotentPost(t *testing.T) {
now := time.Now().UTC()
resolver := &fakeModerationAppeals{
found: true,
link: domain.ModerationAppealLink{
ID: 1, CaseID: 2, AppellantUserID: 3,
TokenHash: [32]byte{1}, ExpiresAt: now.Add(time.Hour),
CreatedAt: now,
},
appeal: domain.ModerationAppeal{
ID: 4, CaseID: 2, Status: domain.ModerationAppealPending,
},
}
handler, err := NewHandler(Config{
StickerSets: fakeResolver{},
ModerationAppeals: resolver,
PublicBaseURL: "https://telesrv.example",
AppName: "Telesrv",
})
if err != nil {
t.Fatal(err)
}
get := httptest.NewRecorder()
handler.ServeHTTP(get, httptest.NewRequest(
http.MethodGet, "/appeal/valid-token", nil,
))
if get.Code != http.StatusOK ||
!strings.Contains(get.Body.String(), "Case #2") ||
!strings.Contains(get.Body.String(), "Do not include passwords") {
t.Fatalf("GET status=%d body=%s", get.Code, get.Body.String())
}
if got := get.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control=%q", got)
}
if got := get.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Fatalf("Referrer-Policy=%q", got)
}
post := httptest.NewRecorder()
postRequest := httptest.NewRequest(
http.MethodPost, "/appeal/valid-token",
strings.NewReader("appeal_text=+Please+review+this.+"),
)
postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handler.ServeHTTP(post, postRequest)
if post.Code != http.StatusOK ||
!strings.Contains(post.Body.String(), "appeal #4") ||
resolver.submitCalls != 1 || resolver.submitText != "Please review this." {
t.Fatalf("POST status=%d calls=%d text=%q body=%s",
post.Code, resolver.submitCalls, resolver.submitText, post.Body.String())
}
resolver.link.AppealID = 4
retry := httptest.NewRecorder()
retryRequest := httptest.NewRequest(
http.MethodPost, "/appeal/valid-token",
strings.NewReader("appeal_text=must+not+resubmit"),
)
retryRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handler.ServeHTTP(retry, retryRequest)
if retry.Code != http.StatusOK || resolver.submitCalls != 1 ||
!strings.Contains(retry.Body.String(), "appeal #4") {
t.Fatalf("retry status=%d calls=%d body=%s",
retry.Code, resolver.submitCalls, retry.Body.String())
}
resolver.appeal.Status = domain.ModerationAppealGranted
granted := httptest.NewRecorder()
handler.ServeHTTP(granted, httptest.NewRequest(
http.MethodGet, "/appeal/valid-token", nil,
))
if granted.Code != http.StatusOK ||
!strings.Contains(granted.Body.String(), "was granted") {
t.Fatalf("granted status=%d body=%s", granted.Code, granted.Body.String())
}
}
func TestHandlerModerationAppealFailsClosed(t *testing.T) {
resolver := &fakeModerationAppeals{}
handler, err := NewHandler(Config{
StickerSets: fakeResolver{}, ModerationAppeals: resolver,
PublicBaseURL: "https://telesrv.example",
})
if err != nil {
t.Fatal(err)
}
missing := httptest.NewRecorder()
handler.ServeHTTP(missing, httptest.NewRequest(
http.MethodGet, "/appeal/invalid", nil,
))
if missing.Code != http.StatusNotFound {
t.Fatalf("missing status=%d", missing.Code)
}
now := time.Now().UTC()
resolver.found = true
resolver.link = domain.ModerationAppealLink{
ID: 1, CaseID: 2, AppellantUserID: 3,
TokenHash: [32]byte{1}, ExpiresAt: now.Add(time.Hour), CreatedAt: now,
}
resolver.submitErr = domain.ErrModerationCaseConflict
conflict := httptest.NewRecorder()
conflictRequest := httptest.NewRequest(
http.MethodPost, "/appeal/valid",
strings.NewReader("appeal_text=still+finalizing"),
)
conflictRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handler.ServeHTTP(conflict, conflictRequest)
if conflict.Code != http.StatusConflict ||
!strings.Contains(conflict.Body.String(), "still being finalized") {
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
}
}
func (f *fakeUniqueGifts) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
f.calls++
if f.err != nil {