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
38 changes: 36 additions & 2 deletions admin/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"mu/internal/auth"
"mu/internal/data"
"mu/internal/flag"
"mu/user"
"mu/wallet"
"mu/work"
)
Expand Down Expand Up @@ -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) == "" {
Expand Down Expand Up @@ -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 <user_id> (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 <user_id>"
}
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 <user_id|all> (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) == "" {
Expand Down
10 changes: 5 additions & 5 deletions blog/blog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/app/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func renderMenu(actions []Action) string {
case a.Label == "Edit":
sb.WriteString(fmt.Sprintf(`<a href="%s" style="%s">Edit</a>`, a.URL, style))
case a.Label == "Delete" && a.Confirm != "":
sb.WriteString(fmt.Sprintf(`<a href="#" style="%s" onclick="if(confirm('%s')){fetch('%s',{method:'DELETE'}).then(function(){window.location='/blog'})};return false;">%s</a>`, style, a.Confirm, a.URL, a.Label))
sb.WriteString(fmt.Sprintf(`<a href="#" style="%s" onclick="if(confirm('%s')){fetch('%s',{method:'DELETE'}).then(function(){window.location=document.referrer||'/'})};return false;">%s</a>`, style, a.Confirm, a.URL, a.Label))
case a.Confirm != "":
sb.WriteString(fmt.Sprintf(`<a href="#" style="%s" onclick="if(confirm('%s')){fetch('%s',{method:'POST'}).then(function(){location.reload()})};return false;">%s</a>`, style, a.Confirm, a.URL, a.Label))
default:
Expand Down
41 changes: 41 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 11 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,17 +199,27 @@ 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
}
answer = strings.TrimSpace(answer)
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)
}
Expand Down
11 changes: 6 additions & 5 deletions social/social.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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" {
Expand Down
106 changes: 101 additions & 5 deletions user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -608,13 +704,13 @@ func Handler(w http.ResponseWriter, r *http.Request) {
// Build status section
statusSection := ""
if profile.Status != "" {
statusSection = fmt.Sprintf(`<p class="info italic mt-3">"%s"</p>`, profile.Status)
statusSection = fmt.Sprintf(`<p class="info italic mt-3">"%s"</p>`, htmlpkg.EscapeString(profile.Status))
}
if len(profile.History) > 0 {
statusSection += `<details style="margin-top:8px;"><summary style="font-size:13px;color:#999;cursor:pointer;">Status history</summary><div style="margin-top:6px;">`
for _, h := range profile.History {
statusSection += fmt.Sprintf(`<p style="font-size:13px;color:#888;margin:4px 0;font-style:italic;">"%s" <span style="color:#bbb;">— %s</span></p>`,
h.Status, app.TimeAgo(h.SetAt))
htmlpkg.EscapeString(h.Status), app.TimeAgo(h.SetAt))
}
statusSection += `</div></details>`
}
Expand All @@ -624,9 +720,9 @@ func Handler(w http.ResponseWriter, r *http.Request) {
if isOwnProfile {
statusEditForm = fmt.Sprintf(`
<form method="POST" class="mt-4">
<input type="text" name="status" placeholder="Set your status..." value="%s" maxlength="100" class="form-input w-full">
<input type="text" name="status" placeholder="Set your status..." value="%s" maxlength="%d" class="form-input w-full">
<button type="submit" class="mt-2">Update Status</button>
</form>`, profile.Status)
</form>`, htmlpkg.EscapeString(profile.Status), MaxStatusLength)
}

// Build message link (only show if not own profile)
Expand Down
Loading