arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
// arcrun OAuth 登入路由
|
||||
// GET /auth/google/start → redirect to Google OAuth
|
||||
// GET /auth/github/start → redirect to GitHub OAuth
|
||||
// GET /auth/callback → exchange code, create session
|
||||
// POST /auth/logout → clear session cookie
|
||||
// GET /me → current user info
|
||||
// PUT /me/api-key/rotate → generate new api key
|
||||
// DELETE /me/api-key → revoke api key
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const authRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type UserRecord = {
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
api_key: string;
|
||||
provider: 'google' | 'github';
|
||||
provider_id: string;
|
||||
created_at: string;
|
||||
revoked?: boolean;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
user_key: string; // "user:{provider}:{provider_id}"
|
||||
api_key: string;
|
||||
email: string;
|
||||
expires_at: number; // unix timestamp ms
|
||||
};
|
||||
|
||||
type OAuthStateRecord = {
|
||||
provider: 'google' | 'github';
|
||||
redirect_back: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getLandingOrigin(c: { req: { raw: Request } }): string {
|
||||
const origin = c.req.raw.headers.get('origin');
|
||||
// 允許的 landing origins
|
||||
const allowed = ['https://arcrun.dev', 'https://www.arcrun.dev'];
|
||||
if (origin && allowed.includes(origin)) return origin;
|
||||
return 'https://arcrun.dev';
|
||||
}
|
||||
|
||||
/** 產生 API Key(HMAC-SHA256 of email,與 /register 相同邏輯) */
|
||||
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
|
||||
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||
const msgData = new TextEncoder().encode(email);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
|
||||
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return 'ak_' + hex.slice(0, 32);
|
||||
}
|
||||
|
||||
/** AES-GCM 加密,回傳 {encrypted, iv}(base64),與 SDK 格式相同 */
|
||||
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
|
||||
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
|
||||
return { encrypted: toB64(enc), iv: toB64(iv) };
|
||||
}
|
||||
|
||||
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
|
||||
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
|
||||
const key = `auth_recipe:${recipe.service}`;
|
||||
const existing = await recipes.get(key);
|
||||
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
|
||||
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
|
||||
}
|
||||
|
||||
/** 產生隨機 token(用於 session ID 和 state) */
|
||||
function randomToken(bytes = 32): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** 從 Cookie header 取 session ID */
|
||||
function getSessionId(req: Request): string | null {
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
const match = cookie.match(/arcrun_session=([a-f0-9]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** 從 Request 取 API Key(X-Arcrun-API-Key header 或 Authorization: Bearer) */
|
||||
function getApiKeyFromRequest(req: Request): string | null {
|
||||
const direct = req.headers.get('x-arcrun-api-key');
|
||||
if (direct) return direct;
|
||||
const auth = req.headers.get('authorization') ?? '';
|
||||
const match = auth.match(/^Bearer\s+(ak_\S+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** 驗證 session → 回傳 user record,或 null */
|
||||
async function resolveSession(c: { req: { raw: Request }; env: Bindings }): Promise<UserRecord | null> {
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) {
|
||||
const sess = await c.env.SESSIONS_KV.get<SessionRecord>(`sess:${sessId}`, 'json');
|
||||
if (sess && sess.expires_at > Date.now()) {
|
||||
const user = await c.env.USERS_KV.get<UserRecord>(sess.user_key, 'json');
|
||||
if (user && !user.revoked) return user;
|
||||
}
|
||||
}
|
||||
// Fallback: API Key header
|
||||
const apiKey = getApiKeyFromRequest(c.req.raw);
|
||||
if (apiKey) {
|
||||
// 掃描 USERS_KV by api_key 太慢;改用 reverse index: apikey:{ak_...} → user_key
|
||||
const userKey = await c.env.USERS_KV.get(`apikey:${apiKey}`);
|
||||
if (userKey) {
|
||||
const user = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
|
||||
if (user && !user.revoked && user.api_key === apiKey) return user;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Google OAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/auth/google/start', async (c) => {
|
||||
const clientId = c.env.GOOGLE_CLIENT_ID;
|
||||
if (!clientId) return c.json({ error: 'Google OAuth not configured' }, 503);
|
||||
|
||||
const state = randomToken(16);
|
||||
const stateRecord: OAuthStateRecord = {
|
||||
provider: 'google',
|
||||
redirect_back: c.req.query('redirect') ?? '/dashboard',
|
||||
created_at: Date.now(),
|
||||
};
|
||||
// state TTL = 10 minutes
|
||||
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
|
||||
|
||||
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
|
||||
});
|
||||
|
||||
// ─── GitHub OAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/auth/github/start', async (c) => {
|
||||
const clientId = c.env.GITHUB_CLIENT_ID;
|
||||
if (!clientId) return c.json({ error: 'GitHub OAuth not configured' }, 503);
|
||||
|
||||
const state = randomToken(16);
|
||||
const stateRecord: OAuthStateRecord = {
|
||||
provider: 'github',
|
||||
redirect_back: c.req.query('redirect') ?? '/dashboard',
|
||||
created_at: Date.now(),
|
||||
};
|
||||
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
|
||||
|
||||
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'read:user user:email',
|
||||
state,
|
||||
});
|
||||
|
||||
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
||||
});
|
||||
|
||||
// ─── OAuth Callback ───────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/auth/callback', async (c) => {
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
const error = c.req.query('error');
|
||||
|
||||
const landingOrigin = getLandingOrigin(c);
|
||||
|
||||
if (error || !code || !state) {
|
||||
return Response.redirect(`${landingOrigin}/login?error=${encodeURIComponent(error ?? 'cancelled')}`, 302);
|
||||
}
|
||||
|
||||
// Validate state
|
||||
const stateRecord = await c.env.SESSIONS_KV.get<OAuthStateRecord>(`state:${state}`, 'json');
|
||||
if (!stateRecord) {
|
||||
return Response.redirect(`${landingOrigin}/login?error=invalid_state`, 302);
|
||||
}
|
||||
await c.env.SESSIONS_KV.delete(`state:${state}`);
|
||||
|
||||
const encryptionKey = c.env.ENCRYPTION_KEY;
|
||||
if (!encryptionKey) {
|
||||
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
|
||||
}
|
||||
|
||||
try {
|
||||
let email: string;
|
||||
let displayName: string;
|
||||
let avatarUrl: string | undefined;
|
||||
let providerId: string;
|
||||
const provider = stateRecord.provider;
|
||||
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
|
||||
|
||||
if (provider === 'google') {
|
||||
// Exchange code for token
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error('google token exchange failed');
|
||||
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
if (!userRes.ok) throw new Error('google userinfo failed');
|
||||
const userInfo = await userRes.json() as {
|
||||
sub: string; email: string; name: string; picture?: string;
|
||||
};
|
||||
email = userInfo.email.toLowerCase();
|
||||
displayName = userInfo.name;
|
||||
avatarUrl = userInfo.picture;
|
||||
providerId = userInfo.sub;
|
||||
|
||||
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
|
||||
if (tokenData.refresh_token) {
|
||||
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
|
||||
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
|
||||
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||
|
||||
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2)
|
||||
void upsertAuthRecipe(c.env.RECIPES, {
|
||||
kind: 'auth_recipe',
|
||||
service: 'google_user',
|
||||
version: 1,
|
||||
primitive: 'oauth2',
|
||||
base_url: 'https://www.googleapis.com',
|
||||
display_name: 'Google(用戶帳號)',
|
||||
oauth2: {
|
||||
token_endpoint: 'https://oauth2.googleapis.com/token',
|
||||
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
|
||||
},
|
||||
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
|
||||
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
// GitHub: exchange code for token
|
||||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: c.env.GITHUB_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GITHUB_CLIENT_SECRET ?? '',
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error('github token exchange failed');
|
||||
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
'User-Agent': 'arcrun',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
},
|
||||
});
|
||||
if (!userRes.ok) throw new Error('github user fetch failed');
|
||||
const userInfo = await userRes.json() as {
|
||||
id: number; login: string; name?: string; avatar_url?: string; email?: string;
|
||||
};
|
||||
|
||||
// GitHub email might be null if private; fetch emails list
|
||||
let ghEmail = userInfo.email ?? '';
|
||||
if (!ghEmail) {
|
||||
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
'User-Agent': 'arcrun',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
},
|
||||
});
|
||||
if (emailsRes.ok) {
|
||||
const emails = await emailsRes.json() as { email: string; primary: boolean; verified: boolean }[];
|
||||
const primary = emails.find(e => e.primary && e.verified);
|
||||
ghEmail = primary?.email ?? emails[0]?.email ?? '';
|
||||
}
|
||||
}
|
||||
if (!ghEmail) throw new Error('github email not available');
|
||||
email = ghEmail.toLowerCase();
|
||||
displayName = userInfo.name ?? userInfo.login;
|
||||
avatarUrl = userInfo.avatar_url;
|
||||
providerId = String(userInfo.id);
|
||||
|
||||
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||
// GitHub 沒有 refresh_token,access_token 長效(直到 revoke)
|
||||
if (tokenData.access_token) {
|
||||
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
|
||||
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
|
||||
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||
|
||||
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
|
||||
void upsertAuthRecipe(c.env.RECIPES, {
|
||||
kind: 'auth_recipe',
|
||||
service: 'github_user',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.github.com',
|
||||
display_name: 'GitHub(用戶帳號)',
|
||||
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
|
||||
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert user record
|
||||
const userKey = `user:${provider}:${providerId}`;
|
||||
const existing = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
|
||||
|
||||
let apiKey: string;
|
||||
if (existing && !existing.revoked) {
|
||||
// Existing user — keep their api key
|
||||
apiKey = existing.api_key;
|
||||
// Update display info
|
||||
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
|
||||
} else {
|
||||
// New user — generate api key (same HMAC logic as /register)
|
||||
apiKey = await generateApiKey(email, encryptionKey);
|
||||
const newUser: UserRecord = {
|
||||
email, display_name: displayName, avatar_url: avatarUrl,
|
||||
api_key: apiKey, provider, provider_id: providerId,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(newUser));
|
||||
// Reverse index for API-Key-based auth
|
||||
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
|
||||
}
|
||||
|
||||
// Create session (TTL 7 days)
|
||||
const sessionId = randomToken(32);
|
||||
const session: SessionRecord = {
|
||||
user_key: userKey,
|
||||
api_key: apiKey,
|
||||
email,
|
||||
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
await c.env.SESSIONS_KV.put(`sess:${sessionId}`, JSON.stringify(session), {
|
||||
expirationTtl: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
const redirectBack = stateRecord.redirect_back.startsWith('/') ? stateRecord.redirect_back : '/dashboard';
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${landingOrigin}${redirectBack}`,
|
||||
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[auth/callback]', err);
|
||||
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Logout ───────────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.post('/auth/logout', async (c) => {
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) {
|
||||
await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
||||
}
|
||||
const landingOrigin = getLandingOrigin(c);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${landingOrigin}/`,
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ─── /me ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/me', async (c) => {
|
||||
const user = await resolveSession(c);
|
||||
if (!user) return c.json({ error: 'not authenticated' }, 401);
|
||||
return c.json({
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
api_key: user.api_key,
|
||||
provider: user.provider,
|
||||
created_at: user.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rotate API Key ───────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.put('/me/api-key/rotate', async (c) => {
|
||||
const user = await resolveSession(c);
|
||||
if (!user) return c.json({ error: 'not authenticated' }, 401);
|
||||
|
||||
// Generate new random key (not HMAC — rotated keys are random)
|
||||
const newRaw = randomToken(24);
|
||||
const newKey = 'ak_' + newRaw;
|
||||
|
||||
const oldKey = user.api_key;
|
||||
const userKey = `user:${user.provider}:${user.provider_id}`;
|
||||
|
||||
const updated: UserRecord = { ...user, api_key: newKey };
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
|
||||
|
||||
// Update reverse index
|
||||
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
|
||||
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
api_key: newKey,
|
||||
message: 'API Key rotated. Your existing workflow credentials are still stored under the old key namespace.',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Revoke API Key ───────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.delete('/me/api-key', async (c) => {
|
||||
const user = await resolveSession(c);
|
||||
if (!user) return c.json({ error: 'not authenticated' }, 401);
|
||||
|
||||
const userKey = `user:${user.provider}:${user.provider_id}`;
|
||||
const revoked: UserRecord = { ...user, revoked: true };
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
|
||||
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
|
||||
|
||||
// Clear session cookie
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
||||
|
||||
return new Response(JSON.stringify({ success: true, message: 'API Key revoked.' }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user