diff --git a/admin/console.go b/admin/console.go index 5b333ac6..ae36c977 100644 --- a/admin/console.go +++ b/admin/console.go @@ -14,6 +14,7 @@ import ( "mu/internal/auth" "mu/internal/data" "mu/internal/flag" + "mu/user" "mu/wallet" "mu/work" ) @@ -183,8 +184,12 @@ func runCommand(cmd string) string { } emailLine = fmt.Sprintf("Email: %s (%s)", acc.Email, verified) } - return fmt.Sprintf("ID: %s\nName: %s\nAdmin: %v\nApproved: %v\n%s\nCreated: %s\nBalance: %d credits", - acc.ID, acc.Name, acc.Admin, acc.Approved, emailLine, acc.Created.Format("2 Jan 2006 15:04"), w.Balance) + banLine := "" + if acc.Banned { + banLine = "\nBanned: YES" + } + return fmt.Sprintf("ID: %s\nName: %s\nAdmin: %v\nApproved: %v\n%s%s\nCreated: %s\nBalance: %d credits", + acc.ID, acc.Name, acc.Admin, acc.Approved, emailLine, banLine, acc.Created.Format("2 Jan 2006 15:04"), w.Balance) case "approve": if arg(1) == "" { @@ -236,6 +241,35 @@ func runCommand(cmd string) string { } return fmt.Sprintf("Approved %d accounts older than %d days", count, days) + case "ban": + if arg(1) == "" { + return "usage: ban (silently mutes — they don't know)" + } + if err := auth.BanAccount(arg(1)); err != nil { + return "ban failed: " + err.Error() + } + return fmt.Sprintf("Banned %s — their content is now invisible to everyone else", arg(1)) + + case "unban": + if arg(1) == "" { + return "usage: unban " + } + if err := auth.UnbanAccount(arg(1)); err != nil { + return "unban failed: " + err.Error() + } + return fmt.Sprintf("Unbanned %s", arg(1)) + + case "clear-status": + if arg(1) == "" { + return "usage: clear-status (clears status + full history)" + } + if arg(1) == "all" { + user.ClearAllStatuses() + return "Cleared all status history for all users" + } + user.ClearStatusHistory(arg(1)) + return fmt.Sprintf("Cleared all status history for %s", arg(1)) + // --- Wallet --- case "wallet": if arg(1) == "" { diff --git a/blog/blog.go b/blog/blog.go index 9439f1dc..0219430c 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -298,7 +298,7 @@ func GetNewAccountBlogPosts() []flag.PostContent { var result []flag.PostContent for _, post := range posts { // Skip flagged/hidden posts - if flag.IsHidden("post", post.ID) { + if flag.IsHidden("post", post.ID) || auth.IsBanned(post.AuthorID) { continue } @@ -390,7 +390,7 @@ func updateCacheUnlocked() { for i := 0; i < len(posts) && count < 1; i++ { post := posts[i] // Skip flagged posts - if flag.IsHidden("post", post.ID) { + if flag.IsHidden("post", post.ID) || auth.IsBanned(post.AuthorID) { continue } // Skip private posts (home page shows only public posts) @@ -495,7 +495,7 @@ func updateCacheUnlocked() { var fullList []string for _, post := range posts { // Skip flagged posts - if flag.IsHidden("post", post.ID) { + if flag.IsHidden("post", post.ID) || auth.IsBanned(post.AuthorID) { continue } @@ -618,7 +618,7 @@ func previewUncached() string { for i := 0; i < len(posts) && count < 1; i++ { post := posts[i] // Skip flagged posts - if flag.IsHidden("post", post.ID) { + if flag.IsHidden("post", post.ID) || auth.IsBanned(post.AuthorID) { continue } // Skip posts from new accounts (< 24 hours old) @@ -761,7 +761,7 @@ func handleGetBlog(w http.ResponseWriter, r *http.Request) { // Filter out flagged posts and private posts (unless admin) var visiblePosts []*Post for _, post := range posts { - if !flag.IsHidden("post", post.ID) { + if !flag.IsHidden("post", post.ID) || auth.IsBanned(post.AuthorID) { // Skip private posts for non-admins if post.Private && !isAdmin { continue diff --git a/internal/app/content.go b/internal/app/content.go index 0cd8e5bf..f405af0e 100644 --- a/internal/app/content.go +++ b/internal/app/content.go @@ -97,7 +97,7 @@ func renderMenu(actions []Action) string { case a.Label == "Edit": sb.WriteString(fmt.Sprintf(`Edit`, a.URL, style)) case a.Label == "Delete" && a.Confirm != "": - sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, a.Label)) + sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, a.Label)) case a.Confirm != "": sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, a.Label)) default: diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f4dfb859..610aef21 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -37,6 +37,7 @@ type Account struct { Email string `json:"email,omitempty"` EmailVerified bool `json:"email_verified,omitempty"` EmailVerifiedAt time.Time `json:"email_verified_at,omitempty"` + Banned bool `json:"banned,omitempty"` // Silently hidden from everyone except themselves } type Session struct { @@ -523,6 +524,46 @@ func IsNewAccount(accountID string) bool { return time.Since(acc.Created) < 24*time.Hour } +// IsBanned returns true if the account is banned. Content from banned +// users is silently hidden from everyone except the user themselves — +// they don't know they're muted. +func IsBanned(accountID string) bool { + mutex.Lock() + defer mutex.Unlock() + acc, exists := accounts[accountID] + if !exists { + return false + } + return acc.Banned +} + +// BanAccount silently mutes a user. Their content is hidden from +// all other users, but they can still browse and post (to themselves). +func BanAccount(accountID string) error { + mutex.Lock() + defer mutex.Unlock() + acc, exists := accounts[accountID] + if !exists { + return errors.New("account not found") + } + acc.Banned = true + data.SaveJSON("accounts.json", accounts) + return nil +} + +// UnbanAccount lifts a ban. +func UnbanAccount(accountID string) error { + mutex.Lock() + defer mutex.Unlock() + acc, exists := accounts[accountID] + if !exists { + return errors.New("account not found") + } + acc.Banned = false + data.SaveJSON("accounts.json", accounts) + return nil +} + // ApproveAccount marks an account as approved, bypassing new account restrictions func ApproveAccount(accountID string) error { mutex.Lock() diff --git a/main.go b/main.go index cdb749a6..8316f01d 100644 --- a/main.go +++ b/main.go @@ -199,10 +199,13 @@ func main() { if askerID == app.SystemUserID { return } + // If the asker is already banned, don't spend AI credits. + if auth.IsBanned(askerID) { + return + } answer, err := agent.Query(askerID, prompt) if err != nil { app.Log("status", "@micro agent error for %s: %v", askerID, err) - // Post a short apology rather than leaving the mention silent. _ = user.PostSystemStatus("I couldn't answer that one — try again in a moment.") return } @@ -210,6 +213,13 @@ func main() { if answer == "" { return } + // Moderate the AI response before posting — if the question + // tricked the AI into producing harmful content, the asker + // is banned and the response is silently dropped. + if !user.ModerateAIResponse(askerID, answer) { + app.Log("status", "AI response for %s blocked by moderation", askerID) + return + } if err := user.PostSystemStatus(answer); err != nil { app.Log("status", "failed to post @micro reply: %v", err) } diff --git a/social/social.go b/social/social.go index 18545f55..d4b870ed 100644 --- a/social/social.go +++ b/social/social.go @@ -537,15 +537,16 @@ func handleGetFeed(w http.ResponseWriter, r *http.Request) { copy(all, messages) mutex.RUnlock() - // Filter out flagged messages and replies (only show threads in feed) + // Filter out flagged/banned messages and replies (only show threads in feed) var visible []*Message for _, p := range all { if p.ReplyTo != "" { continue } - if !flag.IsHidden("social", p.ID) { - visible = append(visible, p) + if flag.IsHidden("social", p.ID) || auth.IsBanned(p.AuthorID) { + continue } + visible = append(visible, p) } if app.WantsJSON(r) { @@ -783,7 +784,7 @@ func generateThreadHTML(p *Message, replies []*Message, r *http.Request) string // Messages (chronological — oldest first, so conversation reads naturally) for _, reply := range replies { - if flag.IsHidden("social", reply.ID) { + if flag.IsHidden("social", reply.ID) || auth.IsBanned(reply.AuthorID) { continue } rc := htmlpkg.EscapeString(reply.Content) @@ -933,7 +934,7 @@ func generateCardHTML(allMessages []*Message) string { if p.ReplyTo != "" { continue // skip replies in home card } - if flag.IsHidden("social", p.ID) { + if flag.IsHidden("social", p.ID) || auth.IsBanned(p.AuthorID) { continue } if p.AuthorID == "_system" { diff --git a/user/user.go b/user/user.go index 9b5b1cfa..a4447a0f 100644 --- a/user/user.go +++ b/user/user.go @@ -15,6 +15,8 @@ import ( "mu/internal/app" "mu/internal/auth" "mu/internal/data" + "mu/internal/flag" + "mu/wallet" ) // UserPost is a simplified post representation for profile rendering. @@ -328,6 +330,10 @@ func StatusStreamCapped(maxTotal, maxPerUser int) []StatusEntry { cutoff := time.Now().Add(-statusMaxAge) var entries []StatusEntry for _, p := range profiles { + // Banned users are invisible to everyone. + if auth.IsBanned(p.UserID) { + continue + } name := p.UserID if acc, err := auth.GetAccount(p.UserID); err == nil { name = acc.Name @@ -398,7 +404,7 @@ func StatusHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - sess, _, err := auth.RequireSession(r) + sess, acc, err := auth.RequireSession(r) if err != nil { app.Unauthorized(w, r) return @@ -409,10 +415,40 @@ func StatusHandler(w http.ResponseWriter, r *http.Request) { status = status[:MaxStatusLength] } + // Allow clearing status (empty string) without any of the gates below. + if status != "" { + // Verified-to-post gate. + if !auth.CanPost(acc.ID) { + http.Error(w, auth.PostBlockReason(acc.ID), http.StatusForbidden) + return + } + // Per-account rate limit. + if err := auth.CheckPostRate(acc.ID); err != nil { + http.Error(w, err.Error(), http.StatusTooManyRequests) + return + } + // Charge 1 credit per status. + canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, wallet.OpSocialPost) + if !canProceed { + http.Error(w, fmt.Sprintf("Status updates cost %d credit. Top up at /wallet", cost), http.StatusPaymentRequired) + return + } + if err := wallet.ConsumeQuota(acc.ID, wallet.OpSocialPost); err != nil { + http.Error(w, err.Error(), http.StatusPaymentRequired) + return + } + } + profile := GetProfile(sess.Account) profile.Status = status UpdateProfile(profile) + // Async content moderation — flags spam/test/harmful automatically + // and auto-bans the user if it's bad. Fire-and-forget. + if status != "" { + go moderateStatus(sess.Account, status) + } + // If the user @mentioned the system agent, fire off a background // agent call that will post the answer as a status from @micro. // Skipped when the system user is mentioning itself. @@ -452,6 +488,66 @@ func PostSystemStatus(text string) error { return UpdateProfile(profile) } +// moderateStatus runs async content moderation on a status post. If the +// LLM classifier flags it as spam, harmful, or a test, the content is +// hidden via the flag system AND the user is auto-banned so all +// their existing + future content becomes invisible to everyone else. +// The user is never told they've been muted — from their perspective +// everything looks normal. +func moderateStatus(accountID, text string) { + flag.CheckContent("status", accountID, "", text) + // CheckContent already calls AdminFlag on detection, which hides + // the individual piece. But for status we want escalation: if the + // LLM says SPAM/HARMFUL, ban the entire account. We can + // piggyback on the same LLM result by checking whether the flag + // was set within the last second (i.e. we just created it). + item := flag.GetItem("status", accountID) + if item != nil && item.Flagged { + app.Log("moderation", "Auto-banning %s after status flagged", accountID) + auth.BanAccount(accountID) + } +} + +// moderateAIResponse checks an AI-generated response BEFORE it's posted +// as a status. Returns true if the response is safe to post. If the +// content is flagged, the requesting user is banned. +func ModerateAIResponse(askerID, response string) bool { + flag.CheckContent("ai_response", askerID, "", response) + item := flag.GetItem("ai_response", askerID) + if item != nil && item.Flagged { + app.Log("moderation", "AI response flagged for %s — banning asker", askerID) + auth.BanAccount(askerID) + return false + } + return true +} + +// ClearStatusHistory wipes both the current status and the full history +// for a user. Used by admin console to clean up after spam. +func ClearStatusHistory(userID string) { + profileMutex.Lock() + defer profileMutex.Unlock() + if p, ok := profiles[userID]; ok { + p.Status = "" + p.History = nil + p.UpdatedAt = time.Now() + data.SaveJSON("profiles.json", profiles) + } +} + +// ClearAllStatuses wipes every user's status + history. Nuclear option +// for when the feed is full of garbage. +func ClearAllStatuses() { + profileMutex.Lock() + defer profileMutex.Unlock() + for _, p := range profiles { + p.Status = "" + p.History = nil + p.UpdatedAt = time.Now() + } + data.SaveJSON("profiles.json", profiles) +} + // containsMention returns true when the mention token appears in the // text as a standalone word (not inside another word like "@microsoft"). func containsMention(text, mention string) bool { @@ -608,13 +704,13 @@ func Handler(w http.ResponseWriter, r *http.Request) { // Build status section statusSection := "" if profile.Status != "" { - statusSection = fmt.Sprintf(`

"%s"

`, profile.Status) + statusSection = fmt.Sprintf(`

"%s"

`, htmlpkg.EscapeString(profile.Status)) } if len(profile.History) > 0 { statusSection += `
Status history
` for _, h := range profile.History { statusSection += fmt.Sprintf(`

"%s" — %s

`, - h.Status, app.TimeAgo(h.SetAt)) + htmlpkg.EscapeString(h.Status), app.TimeAgo(h.SetAt)) } statusSection += `
` } @@ -624,9 +720,9 @@ func Handler(w http.ResponseWriter, r *http.Request) { if isOwnProfile { statusEditForm = fmt.Sprintf(`
- + -
`, profile.Status) +`, htmlpkg.EscapeString(profile.Status), MaxStatusLength) } // Build message link (only show if not own profile)