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
1 change: 0 additions & 1 deletion admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ func AdminHandler(w http.ResponseWriter, r *http.Request) {
<a href="/admin/usage">AI Usage</a>
<a href="/admin/api">API Log</a>
<a href="/admin/blocklist">Blocklist</a>
<a href="/app/blocked">Blocked Users</a>
<a href="/admin/console">Console</a>
<a href="/admin/env">Environment</a>
<a href="/admin/email">Mail Log</a>
Expand Down
183 changes: 60 additions & 123 deletions admin/console.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package admin

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"

"mu/internal/app"
Expand All @@ -12,195 +12,132 @@ import (
"mu/wallet"
)

// ConsoleHandler provides an admin REPL for managing the system.
// ConsoleHandler provides an admin console for managing the system.
func ConsoleHandler(w http.ResponseWriter, r *http.Request) {
_, _, err := auth.RequireAdmin(r)
if err != nil {
app.Forbidden(w, r, "Admin access required")
return
}

// Handle command
cmd := ""
output := ""
if r.Method == "POST" {
handleConsoleCommand(w, r)
r.ParseForm()
cmd = strings.TrimSpace(r.FormValue("cmd"))
if cmd != "" {
output = runCommand(cmd)
}
// Redirect with results to prevent form resubmission
http.Redirect(w, r, "/admin/console?cmd="+url.QueryEscape(cmd)+"&output="+url.QueryEscape(output), http.StatusSeeOther)
return
}

prevOutput := r.URL.Query().Get("output")
// GET — show form + results from redirect
prevCmd := r.URL.Query().Get("cmd")
prevOutput := r.URL.Query().Get("output")

var sb strings.Builder
sb.WriteString(`<div class="card">
<form method="POST" action="/admin/console" id="console-form" style="display:flex;gap:8px">
<input type="text" name="cmd" id="console-input" placeholder="Type a command..." class="form-input" style="flex:1" autocomplete="off" autofocus>
<button type="submit">Run</button>
</form>`)

// Form
sb.WriteString(`<div class="card">`)
sb.WriteString(`<form method="POST" action="/admin/console" style="display:flex;gap:8px">`)
sb.WriteString(fmt.Sprintf(`<input type="text" name="cmd" value="%s" placeholder="Type a command..." class="form-input" style="flex:1" autocomplete="off" autofocus>`, htmlEsc(prevCmd)))
sb.WriteString(`<button type="submit">Run</button>`)
sb.WriteString(`</form>`)

// Output
if prevOutput != "" {
sb.WriteString(fmt.Sprintf(`<pre style="margin-top:12px;font-size:13px;white-space:pre-wrap">&gt; %s
%s</pre>`, prevCmd, prevOutput))
sb.WriteString(fmt.Sprintf(`<pre style="margin-top:12px;font-size:13px;white-space:pre-wrap;background:#f9f9f9;padding:12px;border-radius:6px">%s</pre>`, htmlEsc(prevOutput)))
}
sb.WriteString(`<pre id="console-output" style="margin-top:12px;font-size:13px;white-space:pre-wrap;max-height:500px;overflow-y:auto"></pre>
</div>

<div class="card">
<h4>Commands</h4>
<table style="font-size:13px;width:100%">
<tr><td style="padding:4px 8px"><code>search &lt;query&gt;</code></td><td style="padding:4px 8px;color:#888">Search indexed content</td></tr>
<tr><td style="padding:4px 8px"><code>delete &lt;type&gt; &lt;id&gt;</code></td><td style="padding:4px 8px;color:#888">Delete content by type and ID</td></tr>
<tr><td style="padding:4px 8px"><code>user &lt;id&gt;</code></td><td style="padding:4px 8px;color:#888">View user details</td></tr>
<tr><td style="padding:4px 8px"><code>wallet &lt;id&gt;</code></td><td style="padding:4px 8px;color:#888">View wallet balance</td></tr>
<tr><td style="padding:4px 8px"><code>types</code></td><td style="padding:4px 8px;color:#888">List deletable content types</td></tr>
<tr><td style="padding:4px 8px"><code>stats</code></td><td style="padding:4px 8px;color:#888">Index stats</td></tr>
</table>
</div>

<script>
var form = document.getElementById('console-form');
var input = document.getElementById('console-input');
var output = document.getElementById('console-output');
var history = [];
var histIdx = -1;

form.addEventListener('submit', function(e) {
e.preventDefault();
var cmd = input.value.trim();
if (!cmd) return;
history.unshift(cmd);
histIdx = -1;
output.textContent += '> ' + cmd + '\n';
fetch('/admin/console', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({cmd: cmd})
}).then(function(r) { return r.json(); }).then(function(j) {
output.textContent += j.output + '\n';
output.scrollTop = output.scrollHeight;
}).catch(function(err) {
output.textContent += 'Error: ' + err.message + '\n';
});
input.value = '';
});

input.addEventListener('keydown', function(e) {
if (e.key === 'ArrowUp' && history.length > 0) {
histIdx = Math.min(histIdx + 1, history.length - 1);
input.value = history[histIdx];
e.preventDefault();
} else if (e.key === 'ArrowDown') {
histIdx = Math.max(histIdx - 1, -1);
input.value = histIdx >= 0 ? history[histIdx] : '';
e.preventDefault();
}
});
</script>`)
sb.WriteString(`</div>`)

// Help
sb.WriteString(`<div class="card">
<p class="text-sm text-muted">search &lt;query&gt; · delete &lt;type&gt; &lt;id&gt; · user &lt;id&gt; · wallet &lt;id&gt; · types · stats</p>
</div>`)

html := app.RenderHTMLForRequest("Console", "Admin Console", sb.String(), r)
w.Write([]byte(html))
}

func handleConsoleCommand(w http.ResponseWriter, r *http.Request) {
_, _, err := auth.RequireAdmin(r)
if err != nil {
w.WriteHeader(403)
return
}
func htmlEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

var cmd string
if r.Header.Get("Content-Type") == "application/json" {
var req struct {
Cmd string `json:"cmd"`
}
json.NewDecoder(r.Body).Decode(&req)
cmd = req.Cmd
} else {
r.ParseForm()
cmd = r.FormValue("cmd")
}
cmd = strings.TrimSpace(cmd)
func runCommand(cmd string) string {
parts := strings.Fields(cmd)
if len(parts) == 0 {
app.RespondJSON(w, map[string]string{"output": ""})
return
return ""
}

var output string

switch parts[0] {
case "search":
if len(parts) < 2 {
output = "usage: search <query>"
break
return "usage: search <query>"
}
query := strings.Join(parts[1:], " ")
results := data.Search(query, 20)
if len(results) == 0 {
output = "No results."
} else {
var sb strings.Builder
for _, r := range results {
sb.WriteString(fmt.Sprintf("[%s] %s — %s\n", r.Type, r.ID, r.Title))
}
output = sb.String()
return "No results."
}
var sb strings.Builder
for _, r := range results {
sb.WriteString(fmt.Sprintf("[%s] %s — %s\n", r.Type, r.ID, r.Title))
}
return sb.String()

case "delete":
if len(parts) < 3 {
output = "usage: delete <type> <id>"
break
return "usage: delete <type> <id>"
}
contentType := parts[1]
id := strings.Join(parts[2:], " ")
if err := data.Delete(contentType, id); err != nil {
output = "Error: " + err.Error()
} else {
output = fmt.Sprintf("Deleted %s %s", contentType, id)
return "Error: " + err.Error()
}
return fmt.Sprintf("Deleted %s %s", contentType, id)

case "user":
if len(parts) < 2 {
output = "usage: user <id>"
break
return "usage: user <id>"
}
acc, err := auth.GetAccount(parts[1])
if err != nil {
output = "User not found"
} else {
output = fmt.Sprintf("ID: %s\nName: %s\nAdmin: %v\nCreated: %s",
acc.ID, acc.Name, acc.Admin, acc.Created.Format("2 Jan 2006 15:04"))
return "User not found"
}
return fmt.Sprintf("ID: %s\nName: %s\nAdmin: %v\nCreated: %s",
acc.ID, acc.Name, acc.Admin, acc.Created.Format("2 Jan 2006 15:04"))

case "wallet":
if len(parts) < 2 {
output = "usage: wallet <user_id>"
break
return "usage: wallet <user_id>"
}
w := wallet.GetWallet(parts[1])
usage := wallet.GetDailyUsage(parts[1])
output = fmt.Sprintf("Balance: %d credits\nDaily usage: %d / %d free",
return fmt.Sprintf("Balance: %d credits\nDaily usage: %d / %d free",
w.Balance, usage.Used, wallet.FreeDailyQuota)

case "types":
types := data.DeleteTypes()
if len(types) == 0 {
output = "No deletable types registered."
} else {
output = strings.Join(types, ", ")
return "No deletable types registered."
}
return strings.Join(types, ", ")

case "stats":
stats := data.GetStats()
output = fmt.Sprintf("Index entries: %d\nSQLite: %v", stats.TotalEntries, stats.UsingSQLite)
return fmt.Sprintf("Index entries: %d\nSQLite: %v", stats.TotalEntries, stats.UsingSQLite)

case "help":
output = "Commands: search, delete, user, wallet, types, stats, help"
return "search <query> · delete <type> <id> · user <id> · wallet <id> · types · stats"

default:
output = fmt.Sprintf("Unknown command: %s. Type 'help' for commands.", parts[0])
}

if r.Header.Get("Content-Type") == "application/json" {
app.RespondJSON(w, map[string]string{"output": output})
} else {
// Regular form submit — redirect back with output
http.Redirect(w, r, "/admin/console?output="+strings.ReplaceAll(output, "\n", "%0A")+"&cmd="+cmd, http.StatusSeeOther)
return fmt.Sprintf("Unknown command: %s", parts[0])
}
}
Loading