diff --git a/admin/console.go b/admin/console.go index ae36c977..218600a9 100644 --- a/admin/console.go +++ b/admin/console.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "os" "sort" "strings" "time" @@ -19,6 +20,60 @@ import ( "mu/work" ) +// InviteHandler serves the admin invite page at /admin/invite. +func InviteHandler(w http.ResponseWriter, r *http.Request) { + sess, _, err := auth.RequireAdmin(r) + if err != nil { + app.Forbidden(w, r, "Admin access required") + return + } + + if r.Method == "POST" { + r.ParseForm() + email := strings.TrimSpace(r.FormValue("email")) + if email == "" { + app.BadRequest(w, r, "Email is required") + return + } + code, err := auth.CreateInvite(email, sess.Account) + if err != nil { + app.ServerError(w, r, "Failed to create invite: "+err.Error()) + return + } + base := app.PublicURL() + link := base + "/signup?invite=" + code + + // Try to email the invite. If mail isn't configured, show the link. + if app.EmailSender != nil { + plain := fmt.Sprintf("You've been invited to join Mu.\n\nSign up here: %s\n\nThis link is single-use.", link) + html := fmt.Sprintf(`
You've been invited to join Mu.
This link is single-use.
`, link) + if err := app.EmailSender(email, "You're invited to Mu", plain, html); err != nil { + app.Log("admin", "Failed to email invite to %s: %v", email, err) + } + } + + content := fmt.Sprintf(`Invite created for %s
+ +Link has been emailed (if mail is configured). Single use.
+ +Enter their email address. They'll receive a single-use signup link.
+ +%s
`, now.Format("Monday, 2 January 2006"))) + _, viewerAcc := auth.TrySession(r) + inviteLink := "" + if viewerAcc != nil && viewerAcc.Admin && auth.InviteOnly() { + inviteLink = ` + Invite user` + } + b.WriteString(fmt.Sprintf(`%s%s
`, now.Format("Monday, 2 January 2006"), inviteLink)) // Status card content (will be prepended to left column). // Built by user.RenderStatusStream so the fragment endpoint and the diff --git a/internal/app/app.go b/internal/app/app.go index a9c3d26a..12b2ea8e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -497,6 +497,7 @@ var SignupTemplate = ` %s + %sMu is currently invite-only. Ask an existing member for an invitation.
%s
`, err.Error())))) + return + } + } + if r.Method == "GET" { w.Write([]byte(renderSignup(""))) return @@ -677,6 +708,11 @@ func Signup(w http.ResponseWriter, r *http.Request) { return } + if reason := auth.ValidateUsername(id); reason != "" { + w.Write([]byte(renderSignup(fmt.Sprintf(`%s
`, reason)))) + return + } + if len(secret) == 0 { w.Write([]byte(renderSignup(`Password is required
`))) return @@ -702,6 +738,11 @@ func Signup(w http.ResponseWriter, r *http.Request) { return } + // Consume invite code if present (marks it as used). + if invCode != "" { + auth.ConsumeInvite(invCode, id) + } + // login sess, err := auth.Login(id, secret) if err != nil { diff --git a/internal/app/content.go b/internal/app/content.go index f405af0e..26a77b58 100644 --- a/internal/app/content.go +++ b/internal/app/content.go @@ -97,7 +97,9 @@ 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)) + // Use POST (not DELETE) — handlers check for POST. + // Redirect to the parent listing page, derived from the URL pattern. + sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, 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/invite.go b/internal/auth/invite.go new file mode 100644 index 00000000..29b4ca51 --- /dev/null +++ b/internal/auth/invite.go @@ -0,0 +1,118 @@ +// Invite-only signup. When enabled (INVITE_ONLY=true), new accounts +// can only be created with a valid invite code. Admins generate invite +// codes via the admin console and the code is emailed to the invitee +// as a signup link. +package auth + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "os" + "strings" + "sync" + "time" + + "mu/internal/data" +) + +// Invite stores a pending invitation. +type Invite struct { + Code string `json:"code"` + Email string `json:"email"` // who it was sent to (informational) + CreatedBy string `json:"created_by"` // admin who created it + CreatedAt time.Time `json:"created_at"` + UsedBy string `json:"used_by,omitempty"` // account ID that consumed it + UsedAt time.Time `json:"used_at,omitempty"` +} + +var ( + inviteMu sync.Mutex + invites = map[string]*Invite{} // code → Invite +) + +func init() { + b, err := data.LoadFile("invites.json") + if err == nil && len(b) > 0 { + var loaded map[string]*Invite + if err := json.Unmarshal(b, &loaded); err == nil { + invites = loaded + } + } +} + +// InviteOnly returns true when signup requires an invite code. +// Controlled by the INVITE_ONLY environment variable. +func InviteOnly() bool { + v := strings.ToLower(os.Getenv("INVITE_ONLY")) + return v == "true" || v == "1" || v == "yes" +} + +// CreateInvite generates a new invite code for the given email. +// Returns the code. The caller is responsible for emailing it. +func CreateInvite(email, adminID string) (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + code := hex.EncodeToString(b) + + inviteMu.Lock() + defer inviteMu.Unlock() + + invites[code] = &Invite{ + Code: code, + Email: email, + CreatedBy: adminID, + CreatedAt: time.Now(), + } + saveInvites() + return code, nil +} + +// ValidateInvite checks whether a code is valid (exists and unused). +func ValidateInvite(code string) error { + if code == "" { + return errors.New("invite code required") + } + inviteMu.Lock() + defer inviteMu.Unlock() + + inv, ok := invites[code] + if !ok { + return errors.New("invalid invite code") + } + if inv.UsedBy != "" { + return errors.New("this invite has already been used") + } + return nil +} + +// ConsumeInvite marks an invite code as used by the given account. +func ConsumeInvite(code, accountID string) { + inviteMu.Lock() + defer inviteMu.Unlock() + + if inv, ok := invites[code]; ok { + inv.UsedBy = accountID + inv.UsedAt = time.Now() + saveInvites() + } +} + +// ListInvites returns all invites (for admin console display). +func ListInvites() []*Invite { + inviteMu.Lock() + defer inviteMu.Unlock() + + list := make([]*Invite, 0, len(invites)) + for _, inv := range invites { + list = append(list, inv) + } + return list +} + +func saveInvites() { + data.SaveJSON("invites.json", invites) +} diff --git a/internal/auth/username.go b/internal/auth/username.go new file mode 100644 index 00000000..95b52415 --- /dev/null +++ b/internal/auth/username.go @@ -0,0 +1,34 @@ +// Username validation — blocks obscene, offensive, and impersonation names. +package auth + +import "strings" + +// bannedWords are substrings that are never allowed in usernames. +// Checked case-insensitively. Keep this list tight — it's not a general +// profanity filter, just the words that have no legitimate use in a +// username. +var bannedWords = []string{ + "penis", "cock", "dick", "boob", "tits", "pussy", "vagina", + "fuck", "shit", "cunt", "bitch", "whore", "slut", "nigger", + "nigga", "faggot", "retard", "porn", "hentai", "femboy", + "nazi", "hitler", "jihad", +} + +// ValidateUsername returns an error string if the username is not +// allowed, or "" if it's fine. Called from both web signup and MCP +// signup. This is in addition to the regex format check — a username +// can be well-formed but still banned. +func ValidateUsername(username string) string { + lower := strings.ToLower(username) + for _, w := range bannedWords { + if strings.Contains(lower, w) { + return "That username is not allowed." + } + } + // Block impersonation of system accounts. + if lower == "admin" || lower == "system" || lower == "root" || + lower == "moderator" || lower == "support" { + return "That username is reserved." + } + return "" +} diff --git a/main.go b/main.go index 8316f01d..451e1226 100644 --- a/main.go +++ b/main.go @@ -329,22 +329,32 @@ func main() { // Register MCP auth tools api.RegisterTool(api.Tool{ Name: "signup", - Description: "Create a new account and return a session token", + Description: "Create a new account and return a session token. When invite-only mode is enabled, a valid invite code is required.", Params: []api.ToolParam{ {Name: "id", Type: "string", Description: "Username (4-24 chars, lowercase, starts with letter)", Required: true}, {Name: "secret", Type: "string", Description: "Password (minimum 6 characters)", Required: true}, {Name: "name", Type: "string", Description: "Display name (optional, defaults to username)", Required: false}, + {Name: "invite", Type: "string", Description: "Invite code (required when instance is invite-only)", Required: false}, }, Handle: func(args map[string]any) (string, error) { id, _ := args["id"].(string) secret, _ := args["secret"].(string) name, _ := args["name"].(string) + invite, _ := args["invite"].(string) if id == "" || secret == "" { return "username and password are required", fmt.Errorf("missing fields") } if len(secret) < 6 { return "password must be at least 6 characters", fmt.Errorf("short password") } + if reason := auth.ValidateUsername(id); reason != "" { + return reason, fmt.Errorf("banned username") + } + if auth.InviteOnly() { + if err := auth.ValidateInvite(invite); err != nil { + return err.Error(), err + } + } if name == "" { name = id } @@ -353,6 +363,9 @@ func main() { }); err != nil { return err.Error(), err } + if invite != "" { + auth.ConsumeInvite(invite, id) + } sess, err := auth.Login(id, secret) if err != nil { return "account created but login failed", err @@ -635,6 +648,7 @@ func main() { "/admin/usage": true, "/admin/delete": true, "/admin/console": true, + "/admin/invite": true, "/wallet": false, // Public - shows wallet info; auth checked in handler "/apps": false, // Public - apps directory; auth checked in handler for create/edit @@ -736,6 +750,7 @@ func main() { // admin console http.HandleFunc("/admin/console", admin.ConsoleHandler) + http.HandleFunc("/admin/invite", admin.InviteHandler) // wallet - credits and payments http.HandleFunc("/wallet", wallet.Handler) @@ -1007,8 +1022,36 @@ func main() { return } - // Otherwise serve the HTML profile page + // Otherwise serve the HTML profile page. + // POST /@username updates status — run through the + // same write gate as every other content path. if !strings.Contains(rest, "/") { + if r.Method == "POST" { + op := wallet.OpSocialPost + sess, err := auth.GetSession(r) + if err != nil { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + if !auth.CanPost(sess.Account) { + http.Error(w, auth.PostBlockReason(sess.Account), http.StatusForbidden) + return + } + if err := auth.CheckPostRate(sess.Account); err != nil { + http.Error(w, err.Error(), http.StatusTooManyRequests) + return + } + canProceed, _, cost, _ := wallet.CheckQuota(sess.Account, op) + if !canProceed { + http.Error(w, fmt.Sprintf("This costs %d credit(s). Top up at /wallet", cost), http.StatusPaymentRequired) + return + } + if err := wallet.ConsumeQuota(sess.Account, op); err != nil { + http.Error(w, err.Error(), http.StatusPaymentRequired) + return + } + app.Log("wallet", "Charged %s %d credit(s) for POST /@%s status", sess.Account, wallet.GetOperationCost(op), rest) + } user.Handler(w, r) return } @@ -1037,6 +1080,43 @@ func main() { } } + // ── Centralised write gate ────────────────────────────── + // Every content-creating POST is charged, rate-limited, + // and moderated from ONE place. Individual handlers do + // NOT call CheckQuota/ConsumeQuota — the middleware does + // it so nothing can be forgotten. + if op := chargedWriteOp(r); op != "" { + sess, err := auth.GetSession(r) + if err != nil { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + if !auth.CanPost(sess.Account) { + msg := auth.PostBlockReason(sess.Account) + http.Error(w, msg, http.StatusForbidden) + return + } + if err := auth.CheckPostRate(sess.Account); err != nil { + http.Error(w, err.Error(), http.StatusTooManyRequests) + return + } + canProceed, _, cost, _ := wallet.CheckQuota(sess.Account, op) + if !canProceed { + http.Error(w, fmt.Sprintf("This costs %d credit(s). Top up at /wallet", cost), http.StatusPaymentRequired) + return + } + // Charge up-front. The handler runs only if the + // user can afford it. Failed handler calls (panics, + // 5xx) are rare enough that the lost credit is + // acceptable — and it's the only way to guarantee + // we never forget to charge. + if err := wallet.ConsumeQuota(sess.Account, op); err != nil { + http.Error(w, err.Error(), http.StatusPaymentRequired) + return + } + app.Log("wallet", "Charged %s %d credit(s) for %s %s", sess.Account, wallet.GetOperationCost(op), r.Method, r.URL.Path) + } + http.DefaultServeMux.ServeHTTP(w, r) }), } @@ -1089,6 +1169,41 @@ func main() { app.Log("main", "Server stopped") } +// chargedWriteOp maps a request method + path to the wallet operation +// that should be charged. Returns "" for routes that don't cost credits +// (reads, auth, payments, MCP — MCP has its own QuotaCheck). This is +// the SINGLE source of truth for what costs money on the web/API side. +func chargedWriteOp(r *http.Request) string { + if r.Method != "POST" { + return "" + } + path := r.URL.Path + switch { + // Status updates + case path == "/user/status": + return wallet.OpSocialPost + // Social threads and replies + case path == "/social": + return wallet.OpSocialPost + case path == "/social/thread": + return wallet.OpSocialReply + // Blog — only CREATE is charged (no id param). Updates are free. + case path == "/blog" && r.URL.Query().Get("id") == "": + return wallet.OpBlogCreate + case strings.HasPrefix(path, "/blog/post/") && strings.HasSuffix(path, "/comment"): + return wallet.OpBlogComment + // Apps + case path == "/apps/new": + return wallet.OpSocialPost + case path == "/apps/build/generate", path == "/apps/framework/generate": + return wallet.OpAppBuild + // Work + case path == "/work/post": + return wallet.OpSocialPost + } + return "" +} + // isServerMode returns true when the argument list contains the // `--serve` flag. This is the single signal that switches between the // server and CLI entry points — kept deliberately simple so it can't diff --git a/social/social.go b/social/social.go index d4b870ed..ffd99abf 100644 --- a/social/social.go +++ b/social/social.go @@ -332,15 +332,6 @@ func handleCreateThread(w http.ResponseWriter, r *http.Request) { return } - if !auth.CanPost(acc.ID) { - app.BadRequest(w, r, auth.PostBlockReason(acc.ID)) - return - } - if err := auth.CheckPostRate(acc.ID); err != nil { - app.Forbidden(w, r, err.Error()) - return - } - if err := r.ParseForm(); err != nil { app.BadRequest(w, r, "Failed to parse form") return @@ -360,27 +351,6 @@ func handleCreateThread(w http.ResponseWriter, r *http.Request) { return } - // Charge per post — makes spam expensive. - canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, wallet.OpSocialPost) - if !canProceed { - if app.SendsJSON(r) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(402) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "insufficient_credits", - "message": fmt.Sprintf("Posting requires %d credit. Top up at /wallet", cost), - "cost": cost, - }) - return - } - app.Forbidden(w, r, fmt.Sprintf("Posting requires %d credit. Top up at /wallet", cost)) - return - } - if err := wallet.ConsumeQuota(acc.ID, wallet.OpSocialPost); err != nil { - app.Forbidden(w, r, err.Error()) - return - } - threadID := fmt.Sprintf("%d", time.Now().UnixNano()) p := &Message{ @@ -437,37 +407,11 @@ func handleJSONRequest(w http.ResponseWriter, r *http.Request) { return } - if !auth.CanPost(acc.ID) { - http.Error(w, auth.PostBlockReason(acc.ID), http.StatusForbidden) - return - } - if err := auth.CheckPostRate(acc.ID); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - if len(content) > 500 { http.Error(w, "Messages must be 500 characters or less", 400) return } - // Charge per post. - canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, wallet.OpSocialPost) - if !canProceed { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(402) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "insufficient_credits", - "message": fmt.Sprintf("Posting requires %d credit. Top up at /wallet", cost), - "cost": cost, - }) - return - } - if err := wallet.ConsumeQuota(acc.ID, wallet.OpSocialPost); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - threadID := fmt.Sprintf("%d", time.Now().UnixNano()) p := &Message{ ID: threadID, @@ -613,15 +557,6 @@ func handleCreateReply(w http.ResponseWriter, r *http.Request) { return } - if !auth.CanPost(acc.ID) { - app.BadRequest(w, r, auth.PostBlockReason(acc.ID)) - return - } - if err := auth.CheckPostRate(acc.ID); err != nil { - app.Forbidden(w, r, err.Error()) - return - } - if err := r.ParseForm(); err != nil { app.BadRequest(w, r, "Failed to parse form") return @@ -652,27 +587,6 @@ func handleCreateReply(w http.ResponseWriter, r *http.Request) { return } - // Charge per reply. - canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, wallet.OpSocialReply) - if !canProceed { - if app.SendsJSON(r) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(402) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "insufficient_credits", - "message": fmt.Sprintf("Replies require %d credit. Top up at /wallet", cost), - "cost": cost, - }) - return - } - app.Forbidden(w, r, fmt.Sprintf("Replies require %d credit. Top up at /wallet", cost)) - return - } - if err := wallet.ConsumeQuota(acc.ID, wallet.OpSocialReply); err != nil { - app.Forbidden(w, r, err.Error()) - return - } - replyID := fmt.Sprintf("%d", time.Now().UnixNano()) reply := &Message{ ID: replyID, diff --git a/user/user.go b/user/user.go index 2bc2fd5b..0b42d56e 100644 --- a/user/user.go +++ b/user/user.go @@ -16,7 +16,6 @@ import ( "mu/internal/auth" "mu/internal/data" "mu/internal/flag" - "mu/wallet" ) // UserPost is a simplified post representation for profile rendering. @@ -410,12 +409,14 @@ const MicroMention = "@micro" var AIReplyHook func(askerID, prompt string) // StatusHandler handles POST /user/status to update the current user's status. +// Auth, CanPost, rate limit, and wallet charging are handled by the +// middleware in main.go — this handler only does the domain logic. func StatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - sess, acc, err := auth.RequireSession(r) + sess, _, err := auth.RequireSession(r) if err != nil { app.Unauthorized(w, r) return @@ -426,30 +427,6 @@ 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 - } - } - UpdateStatus(sess.Account, status) // Async content moderation — flags spam/test/harmful automatically @@ -604,9 +581,10 @@ func Handler(w http.ResponseWriter, r *http.Request) { } // Handle POST request for status update (legacy, profile page form). - // Same gates as StatusHandler — CanPost, rate limit, wallet charge. + // Auth, CanPost, rate limit, and wallet charge are handled by the + // middleware in main.go before this handler is called. if r.Method == "POST" { - sess, acc, err := auth.RequireSession(r) + sess, _, err := auth.RequireSession(r) if err != nil { app.Unauthorized(w, r) return @@ -621,26 +599,6 @@ func Handler(w http.ResponseWriter, r *http.Request) { status = status[:MaxStatusLength] } - if status != "" { - if !auth.CanPost(acc.ID) { - app.Forbidden(w, r, auth.PostBlockReason(acc.ID)) - return - } - if err := auth.CheckPostRate(acc.ID); err != nil { - app.Forbidden(w, r, err.Error()) - return - } - canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, wallet.OpSocialPost) - if !canProceed { - app.Forbidden(w, r, fmt.Sprintf("Status updates cost %d credit. Top up at /wallet", cost)) - return - } - if err := wallet.ConsumeQuota(acc.ID, wallet.OpSocialPost); err != nil { - app.Forbidden(w, r, err.Error()) - return - } - } - UpdateStatus(sess.Account, status) if status != "" { diff --git a/work/handlers.go b/work/handlers.go index edf9433d..974d3a80 100644 --- a/work/handlers.go +++ b/work/handlers.go @@ -472,15 +472,6 @@ func handlePost(w http.ResponseWriter, r *http.Request) { return } - if !auth.CanPost(acc.ID) { - respondError(w, r, "/work", auth.PostBlockReason(acc.ID)) - return - } - if err := auth.CheckPostRate(acc.ID); err != nil { - respondError(w, r, "/work", err.Error()) - return - } - var kind, title, description, link string var cost int