feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue