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)) + `
-
-
-
- | Username |
- Name |
- Created |
- Admin |
- Actions |
-
-
- `
-
- 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 += `
-
- | ` + user.ID + ` |
- ` + user.Name + ` |
- ` + createdStr + ` |
-
- |
`)
+ 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