Add recent API request recorder with admin tab and /api/recent endpoint
build / build (push) Successful in 2m31s
build / build (push) Successful in 2m31s
Record the last 10 /v1/ API requests (including errors) in an in-memory ring buffer. Each entry captures request headers (Authorization masked), request body, response status, and response body — all up to 1 MB. - recorder.go: RecentRecorder ring buffer, recordingResponseWriter, withRecording middleware, getRecent handler - server.go: add recorder to Server, wrap /v1/ routes, add /api/recent - admin.html: new 最近请求 tab with lazy-load and expandable cards - recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRecentEntries = 10
|
||||
maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging
|
||||
)
|
||||
|
||||
// RecentEntry captures a single API request and its response for debugging.
|
||||
type RecentEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
RequestHeaders map[string]string `json:"request_headers"`
|
||||
RequestBody string `json:"request_body"`
|
||||
ResponseBody string `json:"response_body"`
|
||||
}
|
||||
|
||||
// RecentRecorder is an in-memory ring buffer that stores the most recent API
|
||||
// requests. It is safe for concurrent use.
|
||||
type RecentRecorder struct {
|
||||
mu sync.Mutex
|
||||
entries []RecentEntry
|
||||
}
|
||||
|
||||
func NewRecentRecorder() *RecentRecorder {
|
||||
return &RecentRecorder{}
|
||||
}
|
||||
|
||||
// Record appends an entry, evicting the oldest when the buffer is full.
|
||||
func (r *RecentRecorder) Record(e RecentEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries = append(r.entries, e)
|
||||
if len(r.entries) > maxRecentEntries {
|
||||
r.entries = r.entries[len(r.entries)-maxRecentEntries:]
|
||||
}
|
||||
}
|
||||
|
||||
// Entries returns a copy of the buffer in newest-first order.
|
||||
func (r *RecentRecorder) Entries() []RecentEntry {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := len(r.entries)
|
||||
out := make([]RecentEntry, n)
|
||||
for i, e := range r.entries {
|
||||
out[n-1-i] = e // reverse: newest first
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordingResponseWriter wraps http.ResponseWriter to capture the status code
|
||||
// and a truncated copy of the response body. It implements http.Flusher so
|
||||
// streaming handlers can flush through the wrapper.
|
||||
type recordingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body bytes.Buffer
|
||||
totalBytes int
|
||||
}
|
||||
|
||||
func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter {
|
||||
return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) WriteHeader(code int) {
|
||||
w.statusCode = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
|
||||
w.totalBytes += len(b)
|
||||
if w.body.Len() < maxBodyCapture {
|
||||
remaining := maxBodyCapture - w.body.Len()
|
||||
if len(b) <= remaining {
|
||||
w.body.Write(b)
|
||||
} else {
|
||||
w.body.Write(b[:remaining])
|
||||
}
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *recordingResponseWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// captureRequestHeaders extracts selected request headers, masking the
|
||||
// Authorization value to avoid leaking API keys.
|
||||
func captureRequestHeaders(r *http.Request) map[string]string {
|
||||
headers := map[string]string{}
|
||||
for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} {
|
||||
if v := r.Header.Get(key); v != "" {
|
||||
if key == "Authorization" {
|
||||
headers[key] = mask(v)
|
||||
} else {
|
||||
headers[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// truncateBody returns the string form of b, truncated to maxBodyCapture
|
||||
// bytes with a marker if the original was longer.
|
||||
func truncateBody(b []byte) string {
|
||||
if len(b) > maxBodyCapture {
|
||||
return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b))
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// formatResponseBody returns the captured response body, with a truncation
|
||||
// marker if the full response exceeded the capture limit.
|
||||
func formatResponseBody(buf *bytes.Buffer, totalBytes int) string {
|
||||
s := buf.String()
|
||||
if totalBytes > maxBodyCapture {
|
||||
s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// withRecording wraps a handler so that each request's headers, body,
|
||||
// response status, and response body (truncated) are captured into the
|
||||
// server's RecentRecorder. It is applied to the /v1/ API routes so that
|
||||
// both successful and error responses are recorded.
|
||||
func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Read and restore the request body so the downstream handler
|
||||
// still sees the full content.
|
||||
var reqBody []byte
|
||||
if r.Body != nil {
|
||||
reqBody, _ = io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewReader(reqBody))
|
||||
}
|
||||
|
||||
recW := newRecordingResponseWriter(w)
|
||||
next(recW, r)
|
||||
|
||||
s.recorder.Record(RecentEntry{
|
||||
Timestamp: start,
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Status: recW.statusCode,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
RequestHeaders: captureRequestHeaders(r),
|
||||
RequestBody: truncateBody(reqBody),
|
||||
ResponseBody: formatResponseBody(&recW.body, recW.totalBytes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getRecent handles GET /api/recent — returns the most recent API requests
|
||||
// as JSON, newest first.
|
||||
func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"requests": s.recorder.Entries(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRecentRecorderRingBuffer verifies that the recorder keeps at most
|
||||
// maxRecentEntries and evicts the oldest when full.
|
||||
func TestRecentRecorderRingBuffer(t *testing.T) {
|
||||
rec := NewRecentRecorder()
|
||||
for i := 0; i < maxRecentEntries+5; i++ {
|
||||
rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200})
|
||||
}
|
||||
entries := rec.Entries()
|
||||
if len(entries) != maxRecentEntries {
|
||||
t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentRecorderNewestFirst verifies entries are returned newest-first.
|
||||
func TestRecentRecorderNewestFirst(t *testing.T) {
|
||||
rec := NewRecentRecorder()
|
||||
rec.Record(RecentEntry{Path: "/first"})
|
||||
rec.Record(RecentEntry{Path: "/second"})
|
||||
rec.Record(RecentEntry{Path: "/third"})
|
||||
entries := rec.Entries()
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("got %d entries", len(entries))
|
||||
}
|
||||
if entries[0].Path != "/third" {
|
||||
t.Fatalf("first entry = %q, want /third", entries[0].Path)
|
||||
}
|
||||
if entries[2].Path != "/first" {
|
||||
t.Fatalf("last entry = %q, want /first", entries[2].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithRecordingCapturesRequestResponse verifies the middleware captures
|
||||
// request headers, request body, response status, and response body.
|
||||
func TestWithRecordingCapturesRequestResponse(t *testing.T) {
|
||||
rec := NewRecentRecorder()
|
||||
s := &Server{recorder: rec}
|
||||
|
||||
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"result":"ok"}`))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer sk-secret-key-12345")
|
||||
req.Header.Set("User-Agent", "test-client/1.0")
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
entries := rec.Entries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("got %d entries, want 1", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Method != "POST" {
|
||||
t.Fatalf("method = %q", e.Method)
|
||||
}
|
||||
if e.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %q", e.Path)
|
||||
}
|
||||
if e.Status != 200 {
|
||||
t.Fatalf("status = %d", e.Status)
|
||||
}
|
||||
if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` {
|
||||
t.Fatalf("request body = %q", e.RequestBody)
|
||||
}
|
||||
if e.ResponseBody != `{"result":"ok"}` {
|
||||
t.Fatalf("response body = %q", e.ResponseBody)
|
||||
}
|
||||
// Authorization must be masked
|
||||
auth, ok := e.RequestHeaders["Authorization"]
|
||||
if !ok || !strings.Contains(auth, "****") {
|
||||
t.Fatalf("authorization not masked: %q", auth)
|
||||
}
|
||||
if strings.Contains(auth, "sk-secret-key-12345") {
|
||||
t.Fatal("authorization leaked raw key")
|
||||
}
|
||||
if e.RequestHeaders["Content-Type"] != "application/json" {
|
||||
t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"])
|
||||
}
|
||||
if e.RequestHeaders["User-Agent"] != "test-client/1.0" {
|
||||
t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithRecordingCapturesErrors verifies error responses are recorded.
|
||||
func TestWithRecordingCapturesErrors(t *testing.T) {
|
||||
rec := NewRecentRecorder()
|
||||
s := &Server{recorder: rec}
|
||||
|
||||
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`))
|
||||
req.Header.Set("Authorization", "Bearer wrong-key")
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
entries := rec.Entries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("got %d entries, want 1", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Status != 401 {
|
||||
t.Fatalf("status = %d, want 401", e.Status)
|
||||
}
|
||||
if !strings.Contains(e.ResponseBody, "invalid api key") {
|
||||
t.Fatalf("response body = %q", e.ResponseBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON.
|
||||
func TestGetRecentAPI(t *testing.T) {
|
||||
rec := NewRecentRecorder()
|
||||
rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200})
|
||||
rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500})
|
||||
s := &Server{recorder: rec}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/recent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.getRecent(w, req)
|
||||
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
if !strings.Contains(string(body), `"ok":true`) {
|
||||
t.Fatalf("response missing ok:true: %s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "/v1/responses") {
|
||||
t.Fatalf("response missing /v1/responses: %s", string(body))
|
||||
}
|
||||
// newest first
|
||||
idxResponses := strings.Index(string(body), "/v1/responses")
|
||||
idxChat := strings.Index(string(body), "/v1/chat/completions")
|
||||
if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat {
|
||||
t.Fatalf("entries not newest-first: %s", string(body))
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,11 @@ type Server struct {
|
||||
loginSession string
|
||||
st *store.Store
|
||||
statsEnabled bool
|
||||
recorder *RecentRecorder
|
||||
}
|
||||
|
||||
func New(cfg config.Config, st *store.Store) http.Handler {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()}
|
||||
if cfg.LoginPassword != "" {
|
||||
s.loginSession = randomSessionToken()
|
||||
}
|
||||
@@ -62,11 +63,12 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
||||
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
||||
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
||||
s.mux.HandleFunc("GET /api/recent", s.withLoginSession(s.getRecent))
|
||||
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
|
||||
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
||||
s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses))
|
||||
s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models)))
|
||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions)))
|
||||
s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses)))
|
||||
}
|
||||
|
||||
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -101,6 +101,16 @@
|
||||
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
|
||||
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
|
||||
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
|
||||
.recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)}
|
||||
.recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
|
||||
.recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px}
|
||||
.recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.recent-card details{margin-top:6px}
|
||||
.recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none}
|
||||
.recent-card summary:hover{color:var(--accent)}
|
||||
.recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -116,6 +126,7 @@
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
|
||||
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
|
||||
<button class="tab" role="tab" data-tab="recent" aria-selected="false">最近请求</button>
|
||||
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
||||
</div>
|
||||
|
||||
@@ -191,6 +202,17 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tabpanel" data-tab="recent" role="tabpanel">
|
||||
<div class="sub-actions">
|
||||
<button class="btn btn-ghost" id="recent-refresh" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>最近 API 请求</h2>
|
||||
<p class="muted" style="margin:0 0 16px">记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。</p>
|
||||
<div id="recent-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tabpanel" data-tab="login" role="tabpanel">
|
||||
<div class="login-card">
|
||||
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
||||
@@ -225,6 +247,7 @@
|
||||
});
|
||||
var hash = location.hash.replace('#', '');
|
||||
if (hash === 'models') activate('models');
|
||||
else if (hash === 'recent') activate('recent');
|
||||
else if (hash === 'login') activate('login');
|
||||
|
||||
var rows = document.querySelectorAll('#daily .bar');
|
||||
@@ -521,6 +544,66 @@
|
||||
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
|
||||
loadIfActive();
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var recentList = document.getElementById('recent-list');
|
||||
if (!recentList) return;
|
||||
var loaded = false;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function formatTime(ts) {
|
||||
var d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
async function loadRecent() {
|
||||
recentList.innerHTML = '<p class="muted">加载中…</p>';
|
||||
try {
|
||||
var res = await fetch('/api/recent');
|
||||
var data = await res.json();
|
||||
if (!data.ok || !data.requests || data.requests.length === 0) {
|
||||
recentList.innerHTML = '<p class="empty">暂无数据</p>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
for (var i = 0; i < data.requests.length; i++) {
|
||||
var r = data.requests[i];
|
||||
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
|
||||
var headers = r.request_headers || {};
|
||||
var headerStr = Object.keys(headers).map(function (k) {
|
||||
return k + ': ' + headers[k];
|
||||
}).join('\n');
|
||||
html += '<div class="recent-card">' +
|
||||
'<div class="recent-head">' +
|
||||
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
|
||||
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
|
||||
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
|
||||
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
|
||||
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
|
||||
'</div>' +
|
||||
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
|
||||
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
|
||||
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
|
||||
'</div>';
|
||||
}
|
||||
recentList.innerHTML = html;
|
||||
} catch (e) {
|
||||
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
var recentTab = document.querySelector('.tab[data-tab="recent"]');
|
||||
if (recentTab) recentTab.addEventListener('click', function () {
|
||||
if (!loaded) { loaded = true; loadRecent(); }
|
||||
});
|
||||
var refreshBtn = document.getElementById('recent-refresh');
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
|
||||
// auto-load if the tab is active on page load
|
||||
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
|
||||
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user