diff --git a/admin/admin.go b/admin/admin.go index 4d6040dc..65159b8d 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "sort" + "strings" + "time" "mu/internal/app" "mu/internal/auth" @@ -39,115 +41,90 @@ func AdminHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(html)) } -// UsersHandler shows and manages users +// UsersHandler shows and manages users with tabs: All, Banned, New. func UsersHandler(w http.ResponseWriter, r *http.Request) { - // Check if user is admin _, acc, err := auth.RequireAdmin(r) if err != nil { app.Forbidden(w, r, "Admin access required") return } - - // Handle POST requests for user management actions if r.Method == "POST" { - if err := r.ParseForm(); err != nil { - app.BadRequest(w, r, "Failed to parse form") - return - } - + r.ParseForm() action := r.FormValue("action") userID := r.FormValue("user_id") - if userID == "" { app.BadRequest(w, r, "User ID required") return } - - targetUser, err := auth.GetAccount(userID) - if err != nil { - app.NotFound(w, r, "User not found") - return - } - switch action { case "toggle_admin": - targetUser.Admin = !targetUser.Admin - auth.UpdateAccount(targetUser) - case "delete": - if err := auth.DeleteAccount(userID); err != nil { - http.Error(w, "Failed to delete user", http.StatusInternalServerError) - return + if u, err := auth.GetAccount(userID); err == nil { + u.Admin = !u.Admin + auth.UpdateAccount(u) } + case "delete": + if userID != acc.ID { auth.DeleteAccount(userID) } + case "ban": + auth.BanAccount(userID) + case "unban": + auth.UnbanAccount(userID) + case "approve": + auth.ApproveAccount(userID) } - - http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + tab := r.FormValue("tab") + redir := "/admin/users" + if tab != "" { redir += "?tab=" + tab } + http.Redirect(w, r, redir, http.StatusSeeOther) return } - - // GET request - show user list users := auth.GetAllAccounts() - - // Sort users by created date (newest first) - sort.Slice(users, func(i, j int) bool { - return users[i].Created.After(users[j].Created) - }) - - content := `

← Admin

-

Users

-

Total: ` + fmt.Sprintf("%d", len(users)) + `

- - - - - - - - - - - ` - - for _, user := range users { - createdStr := user.Created.Format("2006-01-02") - - // Don't allow deleting yourself - deleteButton := "" - if user.ID != acc.ID { - deleteButton = ` - - - - ` + sort.Slice(users, func(i, j int) bool { return users[i].Created.After(users[j].Created) }) + tab := r.URL.Query().Get("tab") + if tab == "" { tab = "all" } + var sb strings.Builder + sb.WriteString(`

← Admin

Users

`) + sb.WriteString(`
`) + for _, t := range []struct{ id, label string }{{"all", "All"}, {"banned", "Banned"}, {"new", "New (24h)"}} { + style := "padding:4px 14px;border-radius:14px;font-size:13px;text-decoration:none;color:#555" + if t.id == tab { style = "padding:4px 14px;border-radius:14px;font-size:13px;text-decoration:none;background:#000;color:#fff" } + sb.WriteString(fmt.Sprintf(`%s`, t.id, style, t.label)) + } + sb.WriteString(`
`) + var filtered []*auth.Account + for _, u := range users { + switch tab { + case "banned": + if u.Banned { filtered = append(filtered, u) } + case "new": + if time.Since(u.Created) < 24*time.Hour { filtered = append(filtered, u) } + default: + filtered = append(filtered, u) } - - content += ` - - - - -
UsernameNameCreatedAdminActions
` + user.ID + `` + user.Name + `` + createdStr + ` -
- - - %d users

`, len(filtered))) + sb.WriteString(``) + for _, u := range filtered { + created := u.Created.Format("2006-01-02") + var badges []string + if u.Admin { badges = append(badges, `admin`) } + if u.Banned { badges = append(badges, `banned`) } + if u.EmailVerified { badges = append(badges, `verified`) } + if u.Approved { badges = append(badges, `approved`) } + statusHTML := strings.Join(badges, " ") + if statusHTML == "" { statusHTML = `` } + var actions []string + if u.ID != acc.ID { + if u.Banned { + actions = append(actions, fmt.Sprintf(``, u.ID, tab)) + } else { + actions = append(actions, fmt.Sprintf(``, u.ID, tab, u.ID)) } - return "" - }() + ` onchange="this.form.submit()" class="cursor-pointer" style="width: 18px; height: 18px;"> - - - - ` + actions = append(actions, fmt.Sprintf(``, u.ID, u.ID, tab)) + } + sb.WriteString(fmt.Sprintf(``, u.ID, u.ID, u.Name, created, statusHTML, strings.Join(actions, " "))) } - - content += ` - -
UsernameNameCreatedStatusActions
- ` + deleteButton + ` -
%s%s%s%s%s
` - - html := app.RenderHTMLForRequest("Admin", "Users", content, r) + sb.WriteString(`
`) + html := app.RenderHTMLForRequest("Admin", "Users", sb.String(), r) w.Write([]byte(html)) } diff --git a/apps/apps.go b/apps/apps.go index ffdc2dc1..5393f27a 100644 --- a/apps/apps.go +++ b/apps/apps.go @@ -685,14 +685,27 @@ func handleCreate(w http.ResponseWriter, r *http.Request) { app.Log("apps", "Created app %q by %s", name, acc.ID) - // Async content moderation — flags and auto-bans on detection. - go func(authorID, appName, appDesc string) { - flag.CheckContent("app", slug, appName, appDesc) - if item := flag.GetItem("app", slug); item != nil && item.Flagged { - app.Log("moderation", "Auto-banning %s after app %q flagged", authorID, appName) + // Async content moderation — check name, description, AND the HTML + // body for inappropriate content. The HTML is stripped to text + URLs + // before being sent to the classifier. Auto-bans on detection. + go func(authorID, appSlug, appName, appDesc, appHTML string) { + // Moderate name + description. + flag.CheckContent("app", appSlug, appName, appDesc) + if item := flag.GetItem("app", appSlug); item != nil && item.Flagged { + app.Log("moderation", "Auto-banning %s after app %q name/desc flagged", authorID, appName) auth.BanAccount(authorID) + return + } + // Moderate the HTML body — extract readable text + URLs. + body := extractAppText(appHTML) + if body != "" { + flag.CheckContent("app_content", appSlug, appName, body) + if item := flag.GetItem("app_content", appSlug); item != nil && item.Flagged { + app.Log("moderation", "Auto-banning %s after app %q content flagged", authorID, appName) + auth.BanAccount(authorID) + } } - }(acc.ID, name, description) + }(acc.ID, slug, name, description, html) // Notify home dashboard to refresh event.Publish(event.Event{Type: "apps_updated"}) @@ -1687,6 +1700,62 @@ func truncate(s string, n int) string { return s[:n] + "..." } +// extractAppText strips HTML tags from app content and returns readable +// text plus any URLs found. Capped at 2000 chars to keep the LLM +// moderation call cheap. Used by the post-creation moderation goroutine. +func extractAppText(html string) string { + if html == "" { + return "" + } + var sb strings.Builder + + // Extract URLs (href="..." and src="..."). + for _, attr := range []string{`href="`, `src="`, `href='`, `src='`} { + idx := 0 + for { + i := strings.Index(html[idx:], attr) + if i < 0 { + break + } + start := idx + i + len(attr) + quote := html[idx+i+len(attr)-1] + end := strings.IndexByte(html[start:], quote) + if end < 0 { + break + } + url := html[start : start+end] + if strings.HasPrefix(url, "http") { + sb.WriteString(url) + sb.WriteByte(' ') + } + idx = start + end + } + } + + // Strip tags — simple state machine, no dependency needed. + inTag := false + for _, c := range html { + if c == '<' { + inTag = true + continue + } + if c == '>' { + inTag = false + sb.WriteByte(' ') + continue + } + if !inTag { + sb.WriteRune(c) + } + } + + text := strings.Join(strings.Fields(sb.String()), " ") + if len(text) > 2000 { + text = text[:2000] + } + return text +} + // SDK JavaScript served at /apps/sdk.js const sdkJS = `// Mu App SDK // Include this in your app: diff --git a/user/status_test.go b/user/status_test.go index a7d36911..f70abad6 100644 --- a/user/status_test.go +++ b/user/status_test.go @@ -64,7 +64,7 @@ func TestStatusStream_ChronologicalOrder(t *testing.T) { } profileMutex.Unlock() - stream := StatusStream(100) + stream := StatusStream(100, "") // Expected order (newest first): // alice "latest" (now) @@ -121,7 +121,7 @@ func TestStatusStream_PerUserCapPreventsFlood(t *testing.T) { profileMutex.Unlock() // Cap: 10 total, 3 per user. Alice should contribute at most 3. - stream := StatusStreamCapped(10, 3) + stream := StatusStreamCapped(10, 3, "") aliceCount := 0 bobCount := 0 @@ -172,7 +172,7 @@ func TestStatusStream_RespectsMax(t *testing.T) { var history []StatusHistory for i := 0; i < 50; i++ { history = append(history, StatusHistory{ - Status: "old", + Status: fmt.Sprintf("old %d", i), SetAt: now.Add(-time.Duration(i+1) * time.Minute), }) } @@ -185,7 +185,7 @@ func TestStatusStream_RespectsMax(t *testing.T) { } profileMutex.Unlock() - stream := StatusStream(10) + stream := StatusStream(10, "") if len(stream) != 10 { t.Errorf("got %d, want 10", len(stream)) } diff --git a/user/user.go b/user/user.go index 5d03430b..5e78423e 100644 --- a/user/user.go +++ b/user/user.go @@ -326,22 +326,23 @@ func RecentStatuses(viewerID string, max int) []StatusEntry { // entries. // // Pass 0 for either cap to disable it. -func StatusStream(max int) []StatusEntry { - return StatusStreamCapped(max, StatusStreamPerUser) +func StatusStream(max int, viewerID string) []StatusEntry { + return StatusStreamCapped(max, StatusStreamPerUser, viewerID) } // StatusStreamCapped is the underlying implementation with explicit -// per-user and total caps. Exported so the profile page and any future -// callers can pick their own shape. -func StatusStreamCapped(maxTotal, maxPerUser int) []StatusEntry { +// per-user and total caps. viewerID is the current viewer — a banned +// user's own posts are included so they don't realise they're muted. +func StatusStreamCapped(maxTotal, maxPerUser int, viewerID string) []StatusEntry { profileMutex.RLock() defer profileMutex.RUnlock() cutoff := time.Now().Add(-statusMaxAge) var entries []StatusEntry for _, p := range profiles { - // Banned users are invisible to everyone. - if auth.IsBanned(p.UserID) { + // Banned users are invisible to everyone — except themselves, + // so they don't realise they've been muted. + if auth.IsBanned(p.UserID) && p.UserID != viewerID { continue } name := p.UserID @@ -387,6 +388,25 @@ func StatusStreamCapped(maxTotal, maxPerUser int) []StatusEntry { sort.Slice(entries, func(i, j int) bool { return entries[i].UpdatedAt.After(entries[j].UpdatedAt) }) + + // Dedupe adjacent identical entries from the same user. If user A + // posts "hello" three times in a row because they didn't realise + // it went through, collapse to one. But if another user's message + // appears in between, both copies stay — the interleaving means + // they're separate conversational moments. + if len(entries) > 1 { + deduped := entries[:1] + for i := 1; i < len(entries); i++ { + prev := deduped[len(deduped)-1] + cur := entries[i] + if cur.UserID == prev.UserID && cur.Status == prev.Status { + continue // skip adjacent duplicate from same user + } + deduped = append(deduped, cur) + } + entries = deduped + } + if maxTotal > 0 && len(entries) > maxTotal { entries = entries[:maxTotal] } @@ -811,7 +831,7 @@ func envInt(key string, def int) int { // stream of recent statuses. Extracted so the fragment endpoint and // the home card can share one code path. func RenderStatusStream(viewerID string) string { - entries := StatusStream(StatusStreamMax) + entries := StatusStream(StatusStreamMax, viewerID) app.Log("status", "RenderStatusStream: %d entries for viewer %s", len(entries), viewerID) var sb strings.Builder