Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 63 additions & 86 deletions admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"net/http"
"sort"
"strings"
"time"

"mu/internal/app"
"mu/internal/auth"
Expand Down Expand Up @@ -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 := `<p><a href="/admin">← Admin</a></p>
<h2>Users</h2>
<p>Total: ` + fmt.Sprintf("%d", len(users)) + `</p>
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Name</th>
<th class="created-col">Created</th>
<th class="center">Admin</th>
<th class="center">Actions</th>
</tr>
</thead>
<tbody>`

for _, user := range users {
createdStr := user.Created.Format("2006-01-02")

// Don't allow deleting yourself
deleteButton := ""
if user.ID != acc.ID {
deleteButton = `<form method="POST" class="d-inline" onsubmit="return confirm('Delete user ` + user.ID + `?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="` + user.ID + `">
<button type="submit" class="btn-danger">Delete</button>
</form>`
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(`<p><a href="/admin">← Admin</a></p><h2>Users</h2>`)
sb.WriteString(`<div style="display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap">`)
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(`<a href="/admin/users?tab=%s" style="%s">%s</a>`, t.id, style, t.label))
}
sb.WriteString(`</div>`)
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 += `
<tr>
<td><strong><a href="/@` + user.ID + `">` + user.ID + `</a></strong></td>
<td>` + user.Name + `</td>
<td class="created-col">` + createdStr + `</td>
<td class="center">
<form method="POST" class="d-inline">
<input type="hidden" name="action" value="toggle_admin">
<input type="hidden" name="user_id" value="` + user.ID + `">
<input type="checkbox" ` + func() string {
if user.Admin {
return "checked"
}
sb.WriteString(fmt.Sprintf(`<p class="text-muted text-sm">%d users</p>`, len(filtered)))
sb.WriteString(`<table class="admin-table"><thead><tr><th>Username</th><th>Name</th><th class="created-col">Created</th><th>Status</th><th class="center">Actions</th></tr></thead><tbody>`)
for _, u := range filtered {
created := u.Created.Format("2006-01-02")
var badges []string
if u.Admin { badges = append(badges, `<span style="background:#000;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px">admin</span>`) }
if u.Banned { badges = append(badges, `<span style="background:#c00;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px">banned</span>`) }
if u.EmailVerified { badges = append(badges, `<span style="background:#22c55e;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px">verified</span>`) }
if u.Approved { badges = append(badges, `<span style="background:#06b;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px">approved</span>`) }
statusHTML := strings.Join(badges, " ")
if statusHTML == "" { statusHTML = `<span class="text-muted" style="font-size:12px">—</span>` }
var actions []string
if u.ID != acc.ID {
if u.Banned {
actions = append(actions, fmt.Sprintf(`<form method="POST" class="d-inline"><input type="hidden" name="action" value="unban"><input type="hidden" name="user_id" value="%s"><input type="hidden" name="tab" value="%s"><button type="submit" style="font-size:12px;padding:2px 8px;border-radius:4px;border:1px solid #22c55e;background:#fff;color:#22c55e;cursor:pointer">Unban</button></form>`, u.ID, tab))
} else {
actions = append(actions, fmt.Sprintf(`<form method="POST" class="d-inline"><input type="hidden" name="action" value="ban"><input type="hidden" name="user_id" value="%s"><input type="hidden" name="tab" value="%s"><button type="submit" style="font-size:12px;padding:2px 8px;border-radius:4px;border:1px solid #c00;background:#fff;color:#c00;cursor:pointer" onclick="return confirm('Ban %s?')">Ban</button></form>`, u.ID, tab, u.ID))
}
return ""
}() + ` onchange="this.form.submit()" class="cursor-pointer" style="width: 18px; height: 18px;">
</form>
</td>
<td class="center">
` + deleteButton + `
</td>
</tr>`
actions = append(actions, fmt.Sprintf(`<form method="POST" class="d-inline" onsubmit="return confirm('Delete %s?')"><input type="hidden" name="action" value="delete"><input type="hidden" name="user_id" value="%s"><input type="hidden" name="tab" value="%s"><button type="submit" class="btn-danger" style="font-size:12px;padding:2px 8px">Delete</button></form>`, u.ID, u.ID, tab))
}
sb.WriteString(fmt.Sprintf(`<tr><td><strong><a href="/@%s">%s</a></strong></td><td>%s</td><td class="created-col">%s</td><td>%s</td><td class="center" style="white-space:nowrap">%s</td></tr>`, u.ID, u.ID, u.Name, created, statusHTML, strings.Join(actions, " ")))
}

content += `
</tbody>
</table>`

html := app.RenderHTMLForRequest("Admin", "Users", content, r)
sb.WriteString(`</tbody></table>`)
html := app.RenderHTMLForRequest("Admin", "Users", sb.String(), r)
w.Write([]byte(html))
}

Expand Down
81 changes: 75 additions & 6 deletions apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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: <script src="/apps/sdk.js"></script>
Expand Down
8 changes: 4 additions & 4 deletions user/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
})
}
Expand All @@ -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))
}
Expand Down
36 changes: 28 additions & 8 deletions user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
Expand Down Expand Up @@ -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
Expand Down
Loading