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
47 changes: 45 additions & 2 deletions apps/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"mu/internal/app"
"mu/internal/auth"
"mu/internal/event"
"mu/wallet"

"github.com/google/uuid"
)
Expand Down Expand Up @@ -282,7 +283,7 @@ func handleGenerate(w http.ResponseWriter, r *http.Request) {
return
}

_, _, err := auth.RequireSession(r)
_, acc, err := auth.RequireSession(r)
if err != nil {
app.Unauthorized(w, r)
return
Expand All @@ -303,6 +304,27 @@ func handleGenerate(w http.ResponseWriter, r *http.Request) {
return
}

// Charge for AI generation — edit if there's existing code, otherwise build.
op := wallet.OpAppBuild
if req.Code != "" {
op = wallet.OpAppEdit
}
canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, op)
if !canProceed {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(402)
json.NewEncoder(w).Encode(map[string]any{
"error": "insufficient_credits",
"message": fmt.Sprintf("AI generation requires %d credits. Top up at /wallet", cost),
"cost": cost,
})
return
}
if err := wallet.ConsumeQuota(acc.ID, op); err != nil {
app.RespondError(w, http.StatusForbidden, err.Error())
return
}

// Build the AI prompt
question := req.Prompt
var rag []string
Expand Down Expand Up @@ -461,7 +483,7 @@ func handleFrameworkGenerate(w http.ResponseWriter, r *http.Request) {
return
}

_, _, err := auth.RequireSession(r)
_, acc, err := auth.RequireSession(r)
if err != nil {
app.Unauthorized(w, r)
return
Expand All @@ -481,6 +503,27 @@ func handleFrameworkGenerate(w http.ResponseWriter, r *http.Request) {
return
}

// Charge for AI generation — edit if blocks present, otherwise build.
op := wallet.OpAppBuild
if len(req.Blocks) > 0 {
op = wallet.OpAppEdit
}
canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, op)
if !canProceed {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(402)
json.NewEncoder(w).Encode(map[string]any{
"error": "insufficient_credits",
"message": fmt.Sprintf("AI generation requires %d credits. Top up at /wallet", cost),
"cost": cost,
})
return
}
if err := wallet.ConsumeQuota(acc.ID, op); err != nil {
app.RespondError(w, http.StatusForbidden, err.Error())
return
}

question := req.Prompt
var rag []string

Expand Down
47 changes: 43 additions & 4 deletions blog/blog.go
Original file line number Diff line number Diff line change
Expand Up @@ -1007,8 +1007,8 @@ func autoTagPost(postID, title, content string) {
})
}

// CreateComment adds a comment to a post
func CreateComment(postID, content, author, authorID string) error {
// CreateComment adds a comment to a post and returns the new comment.
func CreateComment(postID, content, author, authorID string) (*Comment, error) {
comment := &Comment{
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
PostID: postID,
Expand All @@ -1030,7 +1030,10 @@ func CreateComment(postID, content, author, authorID string) error {
mutex.Unlock()

// Save to disk
return data.SaveJSON("comments.json", comments)
if err := data.SaveJSON("comments.json", comments); err != nil {
return nil, err
}
return comment, nil
}

// GetComments retrieves all comments for a post
Expand Down Expand Up @@ -1576,8 +1579,13 @@ func renderComments(postID string, r *http.Request) string {

commentsHTML.WriteString(`<div class="mt-5">`)
// Display newest comments first
isAdmin := acc != nil && acc.Admin
for i := len(postComments) - 1; i >= 0; i-- {
comment := postComments[i]
// Skip flagged/hidden comments unless viewer is admin.
if !isAdmin && flag.IsHidden("comment", comment.ID) {
continue
}
authorLink := comment.Author
if comment.AuthorID != "" {
authorLink = fmt.Sprintf(`<a href="/@%s">%s</a>`, comment.AuthorID, comment.Author)
Expand Down Expand Up @@ -1793,16 +1801,47 @@ func CommentHandler(w http.ResponseWriter, r *http.Request) {
return
}

// New accounts must wait before commenting (anti-spam).
if !auth.CanPost(acc.ID) {
app.Forbidden(w, r, "New accounts must wait 30 minutes before commenting.")
return
}

// Charge per comment — makes spam expensive.
canProceed, _, cost, _ := wallet.CheckQuota(acc.ID, wallet.OpBlogComment)
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("Comments require %d credit. Top up at /wallet", cost),
"cost": cost,
})
return
}
app.Forbidden(w, r, fmt.Sprintf("Comments require %d credit. Top up at /wallet", cost))
return
}
if err := wallet.ConsumeQuota(acc.ID, wallet.OpBlogComment); err != nil {
app.Forbidden(w, r, err.Error())
return
}

// Get the authenticated user
author := acc.Name
authorID := acc.ID

// Create the comment
if err := CreateComment(postID, content, author, authorID); err != nil {
comment, err := CreateComment(postID, content, author, authorID)
if err != nil {
app.ServerError(w, r, "Failed to save comment")
return
}

// Async content moderation — uses the comment's ID, not the post's.
go flag.CheckContent("comment", comment.ID, "", content)

// Redirect back to the post
http.Redirect(w, r, "/blog/post?id="+postID, http.StatusSeeOther)
}
2 changes: 1 addition & 1 deletion blog/opinion_memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ Rules:
return
}

err = CreateComment(post.ID, response, app.SystemUserName, app.SystemUserID)
_, err = CreateComment(post.ID, response, app.SystemUserName, app.SystemUserID)
if err != nil {
app.Log("opinion", "Failed to add comment: %v", err)
return
Expand Down
2 changes: 1 addition & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ func init() {
Name: "MCP Server",
Path: "/mcp",
Method: "POST",
Description: "Model Context Protocol server for AI tool integration. Supports initialize, tools/list, tools/call, and ping methods. Tools include chat, news, blog, video, mail, search, wallet, weather, places, markets, reminder, login, and signup. Metered tools (chat: 5 credits, news_search: 1 credit, video_search: 2 credits, mail_send: 4 credits, weather_forecast: 1 credit + optional 1 credit for pollen data) use the same wallet credit system as the REST API. 100 credits/day included.",
Description: "Model Context Protocol server for AI tool integration. Supports initialize, tools/list, tools/call, and ping methods. Tools include chat, news, blog, video, mail, search, wallet, weather, places, markets, reminder, login, and signup. Metered tools (chat: 5 credits, news_search: 1 credit, video_search: 2 credits, mail_send: 4 credits, weather_forecast: 1 credit + optional 1 credit for pollen data) use the same wallet credit system as the REST API. Pay per call via x402 or top up your account to pay with credits.",
Params: []*Param{
{
Name: "jsonrpc",
Expand Down
13 changes: 13 additions & 0 deletions internal/api/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ type Tool struct {
// Set by main.go to wire in auth + wallet packages without import cycles.
var QuotaCheck func(r *http.Request, op string) (bool, int, error)

// ToolGuard is called before executing any tool — used for tool-specific
// pre-checks (e.g. signup rate limiting per IP). Returning an error blocks
// the call and the error message is returned to the caller. Set by main.go.
var ToolGuard func(r *http.Request, toolName string) error

// PaymentRequiredResponse is called when quota check fails to build x402 payment
// requirements. Returns nil if x402 is not enabled. Set by main.go.
var PaymentRequiredResponse func(w http.ResponseWriter, op string, resource string)
Expand Down Expand Up @@ -661,6 +666,14 @@ func handleToolsCall(w http.ResponseWriter, originalReq *http.Request, req jsonr
return
}

// Tool-specific pre-checks (e.g. signup rate limit per IP).
if ToolGuard != nil {
if err := ToolGuard(originalReq, tool.Name); err != nil {
writeError(w, req.ID, -32000, err.Error())
return
}
}

// Check wallet quota for metered tools
if tool.WalletOp != "" && QuotaCheck != nil {
canProceed, cost, err := QuotaCheck(originalReq, tool.WalletOp)
Expand Down
66 changes: 66 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"

"mu/internal/auth"
Expand All @@ -22,6 +23,63 @@ import (
"github.com/gomarkdown/markdown/parser"
)

// Signup rate limiting per IP — defends against bulk account creation.
// Configurable via SIGNUP_MAX_PER_IP and SIGNUP_WINDOW_HOURS env vars.
var (
signupMu sync.Mutex
signupAttempts = map[string]*signupBucket{}
)

type signupBucket struct {
count int
resetAt time.Time
}

// SignupRateLimit returns true if the IP is allowed to sign up.
// It also records the attempt against the bucket on success.
// Configurable via SIGNUP_MAX_PER_IP (default 3) and SIGNUP_WINDOW_HOURS (default 24).
func SignupRateLimit(ip string) bool {
if ip == "" || ip == "127.0.0.1" || ip == "::1" {
return true // never rate-limit localhost (self-hosted, dev)
}
maxPerIP := envInt("SIGNUP_MAX_PER_IP", 3)
window := time.Duration(envInt("SIGNUP_WINDOW_HOURS", 24)) * time.Hour

signupMu.Lock()
defer signupMu.Unlock()

now := time.Now()
b, ok := signupAttempts[ip]
if !ok || now.After(b.resetAt) {
b = &signupBucket{count: 0, resetAt: now.Add(window)}
signupAttempts[ip] = b
}
if b.count >= maxPerIP {
return false
}
b.count++

// Opportunistic GC to avoid unbounded growth.
if len(signupAttempts) > 10000 {
for k, v := range signupAttempts {
if now.After(v.resetAt) {
delete(signupAttempts, k)
}
}
}
return true
}

func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
return n
}
}
return def
}

// Version for cache busting static assets (generated at startup)
var Version = fmt.Sprintf("%d", time.Now().Unix())

Expand Down Expand Up @@ -539,6 +597,14 @@ func Signup(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
r.ParseForm()

// Per-IP signup rate limit (defends against bulk account creation).
ip := ClientIP(r)
if !SignupRateLimit(ip) {
Log("auth", "Signup rate limit hit for IP: %s", ip)
w.Write([]byte(fmt.Sprintf(SignupTemplate, `<p class="text-error">Too many sign-ups from your network. Please try again later.</p>`)))
return
}

id := r.Form.Get("id")
name := r.Form.Get("name")
secret := r.Form.Get("secret")
Expand Down
26 changes: 26 additions & 0 deletions internal/app/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,35 @@ package app

import (
"fmt"
"net"
"net/http"
"strings"
"time"
)

// ClientIP returns the originating client IP for a request, honouring
// X-Forwarded-For (first hop) and X-Real-IP when present, falling back
// to RemoteAddr. The returned value is the IP only (no port).
func ClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.Index(xff, ","); i > 0 {
xff = xff[:i]
}
ip := strings.TrimSpace(xff)
if ip != "" {
return ip
}
}
if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
return xr
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

func TimeAgo(d time.Time) string {
// Handle zero time
if d.IsZero() {
Expand Down
6 changes: 4 additions & 2 deletions internal/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ Respond with just the single word.`
fmt.Printf("Content moderation: %s %s -> %s\n", contentType, itemID, resp)

if resp == "SPAM" || resp == "TEST" || resp == "LOW_QUALITY" || resp == "HARMFUL" {
Add(contentType, itemID, "system")
fmt.Printf("Auto-flagged %s: %s (reason: %s)\n", contentType, itemID, resp)
// System auto-flag immediately hides the content — do NOT wait for
// 3 user flags. Otherwise spam stays visible until users find it.
AdminFlag(contentType, itemID, "system:"+strings.ToLower(resp))
fmt.Printf("Auto-hidden %s: %s (reason: %s)\n", contentType, itemID, resp)
}
}

Expand Down
16 changes: 12 additions & 4 deletions mail/smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"

"mu/internal/app"
"mu/internal/auth"
Expand Down Expand Up @@ -702,15 +703,22 @@ func parseMultipartRecursive(body io.Reader, boundary string, depth int) string
} else if strings.Contains(contentType, "text/html") && !isAttachment {
textHTML = string(partBody)
app.Log("mail", "Found text/html part (%d bytes)", len(partBody))
} else if isAttachment || strings.Contains(contentType, "application/") {
} else if isAttachment || strings.Contains(contentType, "application/") ||
strings.HasPrefix(contentType, "image/") ||
strings.HasPrefix(contentType, "audio/") ||
strings.HasPrefix(contentType, "video/") {
// Store attachment info (we'll only use it if there's no text body)
attachmentBody = partBody
attachmentContentType = contentType
app.Log("mail", "Found attachment: %s (%d bytes)", contentType, len(partBody))
} else {
// Unknown part type - preserve it
app.Log("mail", "Unknown part type: %s (%d bytes) - preserving", contentType, len(partBody))
allParts = append(allParts, fmt.Sprintf("\n\n[%s]\n%s", contentType, string(partBody)))
// Unknown part type - skip binary content, preserve text-like parts only
if utf8.Valid(partBody) {
app.Log("mail", "Unknown part type: %s (%d bytes) - preserving", contentType, len(partBody))
allParts = append(allParts, fmt.Sprintf("\n\n[%s]\n%s", contentType, string(partBody)))
} else {
app.Log("mail", "Unknown part type: %s (%d bytes) - skipping (binary)", contentType, len(partBody))
}
}
}

Expand Down
Loading
Loading