feat: complete v0.5 operations foundation
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
requireSameOrigin(request); const admin = await requireUser(); if (admin.role !== "admin") throw new Error("Forbidden");
|
||||
const form = await request.formData(); const action = String(form.get("action")); const id = Number(form.get("id"));
|
||||
if (action === "hide-post") { db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(id); db.prepare("UPDATE reports SET resolved=1 WHERE post_id=?").run(id); }
|
||||
else if (action === "restore-post") db.prepare("UPDATE posts SET hidden=0,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
|
||||
else if (action === "disable-user") { if (id === admin.id) throw new Error("Cannot disable yourself"); db.prepare("UPDATE users SET disabled=1 WHERE id=?").run(id); }
|
||||
else if (action === "enable-user") db.prepare("UPDATE users SET disabled=0 WHERE id=?").run(id);
|
||||
else if (action === "reset-password") { const password = String(form.get("password") || ""); if (password.length < 10) throw new Error("Invalid password"); db.prepare("UPDATE users SET password_hash=? WHERE id=?").run(await bcrypt.hash(password, 12), id); }
|
||||
else if (action === "resolve-report") db.prepare("UPDATE reports SET resolved=1 WHERE id=?").run(id);
|
||||
else throw new Error("Invalid action");
|
||||
return NextResponse.redirect(externalUrl(request, "/admin?updated=1"));
|
||||
} catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); }
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
|
||||
export async function POST(req: Request) { const form = await req.formData(); const username=String(form.get("username")||""); const password=String(form.get("password")||""); const user=db.prepare("SELECT id,username,password_hash,role,disabled FROM users WHERE username=?").get(username) as any;
|
||||
if (!user || user.disabled || !(await bcrypt.compare(password,user.password_hash))) return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); await createSession({id:user.id,username:user.username,role:user.role}); return NextResponse.redirect(externalUrl(req,"/dashboard")); }
|
||||
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; import { withinRateLimit } from "@/lib/rate-limit"; import { clientIp, requireSameOrigin } from "@/lib/security";
|
||||
export async function POST(req: Request) { try { requireSameOrigin(req); if (!withinRateLimit(`login:${clientIp(req)}`, 8, 15 * 60_000)) return NextResponse.redirect(externalUrl(req,"/login?error=rate-limited")); const form = await req.formData(); const username=String(form.get("username")||""); const password=String(form.get("password")||""); const user=db.prepare("SELECT id,username,password_hash,role,disabled FROM users WHERE username=?").get(username) as any;
|
||||
if (!user || user.disabled || !(await bcrypt.compare(password,user.password_hash))) return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); await createSession({id:user.id,username:user.username,role:user.role}); return NextResponse.redirect(externalUrl(req,"/dashboard")); } catch { return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); } }
|
||||
|
||||
@@ -1 +1 @@
|
||||
import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; export async function POST(req:Request){await clearSession();return NextResponse.redirect(externalUrl(req,"/"));}
|
||||
import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; import { requireSameOrigin } from "@/lib/security"; export async function POST(req:Request){try { requireSameOrigin(req); await clearSession(); } catch {} return NextResponse.redirect(externalUrl(req,"/"));}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData();
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData();
|
||||
const currentPassword = String(form.get("currentPassword") || ""); const newPassword = String(form.get("newPassword") || ""); const confirmPassword = String(form.get("confirmPassword") || "");
|
||||
if (newPassword.length < 10) throw new Error("新密碼至少需要 10 個字元");
|
||||
if (newPassword !== confirmPassword) throw new Error("兩次新密碼不一致");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
|
||||
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; import { clientIp, requireSameOrigin } from "@/lib/security"; import { withinRateLimit } from "@/lib/rate-limit";
|
||||
export async function POST(req: Request) { const form = await req.formData(); const username = String(form.get("username") || "").trim(); const password = String(form.get("password") || "");
|
||||
try { requireSameOrigin(req); } catch { return NextResponse.redirect(externalUrl(req,"/register?error=invalid")); } if (!withinRateLimit(`register:${clientIp(req)}`, 5, 60 * 60_000)) return NextResponse.redirect(externalUrl(req,"/register?error=rate-limited"));
|
||||
if (!/^[A-Za-z0-9_-]{3,32}$/.test(username) || password.length < 10) return NextResponse.redirect(externalUrl(req,"/register?error=invalid"));
|
||||
try { const out = db.prepare("INSERT INTO users(username,password_hash) VALUES (?,?)").run(username, await bcrypt.hash(password, 12)); await createSession({ id: Number(out.lastInsertRowid), username, role: "user" }); return NextResponse.redirect(externalUrl(req,"/dashboard")); } catch { return NextResponse.redirect(externalUrl(req,"/register?error=taken")); }
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const kind = String(form.get("kind"));
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const kind = String(form.get("kind"));
|
||||
if (!postId || !["saved", "later"].includes(kind)) throw new Error("Invalid bookmark");
|
||||
const post = db.prepare("SELECT id FROM posts WHERE id=? AND visibility='PUBLIC' AND hidden=0").get(postId); if (!post) throw new Error("Post not found");
|
||||
const existing = db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, postId) as { kind: string } | undefined;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { notify } from "@/lib/notifications";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const content = String(form.get("content") || "").trim();
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const content = String(form.get("content") || "").trim();
|
||||
if (!postId || !content || content.length > 5000) throw new Error("Invalid comment");
|
||||
const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined;
|
||||
if (!post) throw new Error("Post not found");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function GET() {
|
||||
try {
|
||||
db.prepare("SELECT 1").get();
|
||||
const failedJobs = Number((db.prepare("SELECT count(*) AS count FROM sync_jobs WHERE status='failed'").get() as { count: number }).count);
|
||||
return NextResponse.json({ ok: true, version: process.env.APP_VERSION || "development", database: "ok", failedJobs, timestamp: new Date().toISOString() });
|
||||
} catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); }
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData(); const id = Number(form.get("id"));
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const id = Number(form.get("id"));
|
||||
if (id) db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?").run(id, user.id);
|
||||
else db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE user_id=? AND read_at IS NULL").run(user.id);
|
||||
return NextResponse.redirect(externalUrl(req, "/notifications"));
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path";
|
||||
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const max=Number(process.env.UPLOAD_MAX_BYTES||10485760);const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){if(file.size>max)throw new Error(`${file.name} exceeds upload limit`);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path"; import { requireSameOrigin } from "@/lib/security"; import { validateUpload } from "@/lib/uploads";
|
||||
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{requireSameOrigin(req);const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);if(files.length>10)throw new Error("最多可上傳 10 個附件");const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){await validateUpload(file);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { notify } from "@/lib/notifications";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
const allowed = new Set(["👍", "❤️", "🎉", "🤔"]);
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const emoji = String(form.get("emoji"));
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const emoji = String(form.get("emoji"));
|
||||
if (!postId || !allowed.has(emoji)) throw new Error("Invalid reaction");
|
||||
const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined;
|
||||
if (!post) throw new Error("Post not found");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
requireSameOrigin(request); const user = await requireUser(); const form = await request.formData();
|
||||
const postId = Number(form.get("postId")); const reason = String(form.get("reason") || "").trim();
|
||||
if (!postId || reason.length < 3 || reason.length > 500) throw new Error("Invalid report");
|
||||
const post = db.prepare("SELECT id FROM posts WHERE id=? AND hidden=0").get(postId);
|
||||
if (!post) throw new Error("Post not found");
|
||||
db.prepare("INSERT INTO reports(post_id,reporter_id,reason) SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM reports WHERE post_id=? AND reporter_id=? AND resolved=0)").run(postId, user.id, reason, postId, user.id);
|
||||
return NextResponse.redirect(externalUrl(request, `/posts/${postId}?reported=1`));
|
||||
} catch { return NextResponse.redirect(externalUrl(request, "/")); }
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const form = await req.formData(); const action = String(form.get("action") || "");
|
||||
requireSameOrigin(req); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const form = await req.formData(); const action = String(form.get("action") || "");
|
||||
const source = db.prepare("SELECT id,user_id,base_url,token_encrypted FROM sources WHERE id=?").get(id) as { id: number; user_id: number; base_url: string; token_encrypted: string } | undefined;
|
||||
const member = db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id);
|
||||
if (!source || !member) throw new Error("Source not found");
|
||||
|
||||
@@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
|
||||
requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
|
||||
const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id);
|
||||
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const secret = createWebhookSecret();
|
||||
|
||||
@@ -5,10 +5,11 @@ import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData();
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData();
|
||||
const name = String(form.get("name") || "").trim(); const rawBaseUrl = String(form.get("baseUrl") || "").trim(); const token = String(form.get("token") || "").trim();
|
||||
let baseUrl = "";
|
||||
try { const url = new URL(rawBaseUrl); if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); baseUrl = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; } catch { throw new Error("Invalid source URL"); }
|
||||
|
||||
@@ -3,10 +3,11 @@ import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = await requireUser(); const form = await req.formData(); const sourceId = Number(form.get("sourceId"));
|
||||
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const sourceId = Number(form.get("sourceId"));
|
||||
const source = db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId, user.id);
|
||||
if (!source) throw new Error("Source not available");
|
||||
const created = queuePull(sourceId, "manual");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { withinRateLimit } from "@/lib/rate-limit";
|
||||
import { clientIp } from "@/lib/security";
|
||||
import { logEvent } from "@/lib/observability";
|
||||
import { webhookSecretMatches } from "@/lib/webhook";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
|
||||
@@ -9,11 +11,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ sou
|
||||
const id = Number(sourceId);
|
||||
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
|
||||
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
|
||||
if (!withinRateLimit(`webhook:${id}:${forwarded}`)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
if (!withinRateLimit(`webhook:${id}:${clientIp(request)}`, 30, 60_000)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
let payload: unknown = {};
|
||||
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
|
||||
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
|
||||
const queued = queuePull(id, "webhook", payload);
|
||||
logEvent("info", "webhook_received", { sourceId: id, queued });
|
||||
return NextResponse.json({ ok: true, queued });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user