Initial Commit
This commit is contained in:
commit
d0417339c1
158 files changed
+31368
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getAgentDir } from './paths';
|
||||
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||
|
||||
class Logger {
|
||||
private logFilePath: string;
|
||||
|
||||
constructor() {
|
||||
const agentDir = getAgentDir();
|
||||
if (!fs.existsSync(agentDir)) {
|
||||
try {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to create agent directory for logs', e);
|
||||
}
|
||||
}
|
||||
this.logFilePath = path.join(agentDir, 'app.log');
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, message: string, ...args: unknown[]): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedArgs = args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ');
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message} ${formattedArgs}`;
|
||||
}
|
||||
|
||||
private writeToFile(formattedMessage: string) {
|
||||
try {
|
||||
fs.appendFileSync(this.logFilePath, formattedMessage + '\n');
|
||||
} catch (e) {
|
||||
console.error('Failed to write to log file', e);
|
||||
}
|
||||
}
|
||||
|
||||
private writeToConsole(level: LogLevel, message: string, ...args: unknown[]) {
|
||||
const colorMap: Record<LogLevel, string> = {
|
||||
info: '\x1b[36m', // Cyan
|
||||
warn: '\x1b[33m', // Yellow
|
||||
error: '\x1b[31m', // Red
|
||||
debug: '\x1b[90m', // Gray
|
||||
};
|
||||
const reset = '\x1b[0m';
|
||||
const color = colorMap[level];
|
||||
|
||||
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||
const formattedMessage = this.formatMessage(level, message, ...args);
|
||||
this.writeToConsole(level, message, ...args);
|
||||
this.writeToFile(formattedMessage);
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]) {
|
||||
this.log('info', message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
this.log('warn', message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]) {
|
||||
this.log('error', message, ...args);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]) {
|
||||
this.log('debug', message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
@@ -0,0 +1,199 @@
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Checks if the current platform is WSL.
|
||||
* @returns {boolean} True if the current platform is WSL, false otherwise.
|
||||
*/
|
||||
export function isWsl(): boolean {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try {
|
||||
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
||||
return version.includes('microsoft') && version.includes('wsl');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedWindowsUser: string | null = null;
|
||||
|
||||
/**
|
||||
* Gets the Windows username.
|
||||
* @returns {string} The Windows username.
|
||||
*/
|
||||
function getWindowsUser(): string {
|
||||
if (cachedWindowsUser) return cachedWindowsUser;
|
||||
|
||||
// Strategy 1: Try cmd.exe to get actual Windows username (most reliable for WSL)
|
||||
try {
|
||||
// We use execSync because this function needs to be synchronous
|
||||
// and it's usually called once or cached.
|
||||
const stdout = execSync('/mnt/c/Windows/System32/cmd.exe /c "echo %USERNAME%"', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
// Output might contain warnings about UNC paths, so we take the last line
|
||||
const lines = stdout.trim().split(/\r?\n/);
|
||||
const user = lines[lines.length - 1].trim();
|
||||
|
||||
if (user) {
|
||||
cachedWindowsUser = user;
|
||||
return user;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// Strategy 2: Try to match current Linux username
|
||||
const linuxUser = os.userInfo().username;
|
||||
if (fs.existsSync(`/mnt/c/Users/${linuxUser}`)) {
|
||||
cachedWindowsUser = linuxUser;
|
||||
return linuxUser;
|
||||
}
|
||||
|
||||
// Strategy 3: List users and pick first likely candidate
|
||||
try {
|
||||
const users = fs
|
||||
.readdirSync('/mnt/c/Users')
|
||||
.filter(
|
||||
(u) =>
|
||||
!['Public', 'Default', 'Default User', 'All Users', 'desktop.ini'].includes(u) &&
|
||||
fs.statSync(path.join('/mnt/c/Users', u)).isDirectory(),
|
||||
);
|
||||
if (users.length > 0) {
|
||||
cachedWindowsUser = users[0];
|
||||
return users[0];
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when reading directory
|
||||
}
|
||||
|
||||
return 'User'; // Fallback
|
||||
}
|
||||
|
||||
export function getAppDataDir(): string {
|
||||
const home = os.homedir();
|
||||
|
||||
if (isWsl()) {
|
||||
const winUser = getWindowsUser();
|
||||
return `/mnt/c/Users/${winUser}/AppData/Roaming/Antigravity`;
|
||||
}
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return path.join(home, 'Library', 'Application Support', 'Antigravity');
|
||||
case 'win32':
|
||||
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Antigravity');
|
||||
case 'linux':
|
||||
return path.join(home, '.config', 'Antigravity');
|
||||
default:
|
||||
return path.join(home, '.antigravity');
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentDir(): string {
|
||||
return path.join(os.homedir(), '.antigravity-agent');
|
||||
}
|
||||
|
||||
export function getAccountsFilePath(): string {
|
||||
return path.join(getAgentDir(), 'antigravity_accounts.json');
|
||||
}
|
||||
|
||||
export function getBackupsDir(): string {
|
||||
return path.join(getAgentDir(), 'backups');
|
||||
}
|
||||
|
||||
export function getCloudAccountsDbPath(): string {
|
||||
return path.join(getAgentDir(), 'cloud_accounts.db');
|
||||
}
|
||||
|
||||
export function getAntigravityDbPaths(): string[] {
|
||||
const appData = getAppDataDir();
|
||||
const paths: string[] = [];
|
||||
const home = os.homedir();
|
||||
|
||||
if (isWsl()) {
|
||||
// Assume standard structure: AppData/Roaming/Antigravity/User/globalStorage/state.vscdb
|
||||
// appData is already resolved to Roaming/Antigravity in getAppDataDir()
|
||||
paths.push(path.join(appData, 'User', 'globalStorage', 'state.vscdb'));
|
||||
paths.push(path.join(appData, 'User', 'state.vscdb'));
|
||||
paths.push(path.join(appData, 'state.vscdb'));
|
||||
return paths;
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
paths.push(path.join(appData, 'state.vscdb'));
|
||||
return paths;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// Standard path
|
||||
paths.push(
|
||||
path.join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Antigravity',
|
||||
'User',
|
||||
'globalStorage',
|
||||
'state.vscdb',
|
||||
),
|
||||
);
|
||||
// Fallback path
|
||||
paths.push(path.join(home, 'Library', 'Application Support', 'Antigravity', 'state.vscdb'));
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Windows
|
||||
// Standard path
|
||||
paths.push(path.join(appData, 'User', 'globalStorage', 'state.vscdb'));
|
||||
// Fallback paths
|
||||
paths.push(path.join(appData, 'User', 'state.vscdb'));
|
||||
paths.push(path.join(appData, 'state.vscdb'));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Keep for backward compatibility if needed, but prefer getAntigravityDbPaths
|
||||
export function getAntigravityDbPath(): string {
|
||||
const paths = getAntigravityDbPaths();
|
||||
return paths.length > 0 ? paths[0] : '';
|
||||
}
|
||||
|
||||
export function getAntigravityExecutablePath(): string {
|
||||
if (isWsl()) {
|
||||
const winUser = getWindowsUser();
|
||||
return `/mnt/c/Users/${winUser}/AppData/Local/Programs/Antigravity/Antigravity.exe`;
|
||||
}
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return '/Applications/Antigravity.app/Contents/MacOS/Antigravity';
|
||||
case 'win32': {
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
||||
|
||||
const possiblePaths = [
|
||||
path.join(localAppData, 'Programs', 'Antigravity', 'Antigravity.exe'),
|
||||
path.join(programFiles, 'Antigravity', 'Antigravity.exe'),
|
||||
path.join(programFilesX86, 'Antigravity', 'Antigravity.exe'),
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return possiblePaths[0];
|
||||
}
|
||||
case 'linux':
|
||||
return '/usr/share/antigravity/antigravity';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
export class ProtobufUtils {
|
||||
static encodeVarint(value: number): Uint8Array {
|
||||
// Note: Javascript bitwise operators treat operands as 32-bit integers.
|
||||
// However, for typical token lengths and small field IDs, this is fine.
|
||||
// Expiry timestamp (seconds) fits in 32-bit (until 2038) but might exceed if it's milliseconds.
|
||||
// If value is larger than 2^32, we need BigInt.
|
||||
|
||||
const buf: number[] = [];
|
||||
let val = BigInt(value);
|
||||
|
||||
while (val >= 128n) {
|
||||
buf.push(Number((val & 127n) | 128n));
|
||||
val >>= 7n;
|
||||
}
|
||||
buf.push(Number(val));
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
static readVarint(data: Uint8Array, offset: number): { value: bigint; nextOffset: number } {
|
||||
let result = 0n;
|
||||
let shift = 0n;
|
||||
let pos = offset;
|
||||
|
||||
while (pos < data.length) {
|
||||
const byte = BigInt(data[pos]);
|
||||
result |= (byte & 127n) << shift;
|
||||
pos++;
|
||||
if ((byte & 128n) === 0n) {
|
||||
return { value: result, nextOffset: pos };
|
||||
}
|
||||
shift += 7n;
|
||||
}
|
||||
throw new Error('Incomplete varint data');
|
||||
}
|
||||
|
||||
// Skip a field based on wire type
|
||||
static skipField(data: Uint8Array, offset: number, wireType: number): number {
|
||||
switch (wireType) {
|
||||
case 0: // Varint
|
||||
return this.readVarint(data, offset).nextOffset;
|
||||
case 1: // 64-bit
|
||||
return offset + 8;
|
||||
case 2: // Length-delimited
|
||||
const { value: length, nextOffset } = this.readVarint(data, offset);
|
||||
return nextOffset + Number(length);
|
||||
case 5: // 32-bit
|
||||
return offset + 4;
|
||||
default:
|
||||
throw new Error(`Unknown wire type: ${wireType}`);
|
||||
}
|
||||
}
|
||||
|
||||
static removeField(data: Uint8Array, fieldNum: number): Uint8Array {
|
||||
const result: number[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < data.length) {
|
||||
const startOffset = offset;
|
||||
const { value: tag, nextOffset } = this.readVarint(data, offset);
|
||||
const wireType = Number(tag & 7n);
|
||||
const currentField = Number(tag >> 3n);
|
||||
|
||||
if (currentField === fieldNum) {
|
||||
// Skip
|
||||
offset = this.skipField(data, nextOffset, wireType);
|
||||
} else {
|
||||
// Copy
|
||||
const endOffset = this.skipField(data, nextOffset, wireType);
|
||||
for (let i = startOffset; i < endOffset; i++) {
|
||||
result.push(data[i]);
|
||||
}
|
||||
offset = endOffset;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
// Create string field (WireType 2)
|
||||
static createStringField(fieldNum: number, value: string): Uint8Array {
|
||||
const tag = (fieldNum << 3) | 2;
|
||||
const utf8Encode = new TextEncoder();
|
||||
const bytes = utf8Encode.encode(value);
|
||||
|
||||
const tagBytes = this.encodeVarint(tag);
|
||||
const lenBytes = this.encodeVarint(bytes.length);
|
||||
|
||||
const result = new Uint8Array(tagBytes.length + lenBytes.length + bytes.length);
|
||||
result.set(tagBytes, 0);
|
||||
result.set(lenBytes, tagBytes.length);
|
||||
result.set(bytes, tagBytes.length + lenBytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create timestamp field (Field 4 -> Field 1 varint)
|
||||
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
||||
// Timestamp message format: Field 1 (seconds) as varint
|
||||
const innerTag = (1 << 3) | 0;
|
||||
const innerTagBytes = this.encodeVarint(innerTag);
|
||||
const secondsBytes = this.encodeVarint(seconds);
|
||||
|
||||
const innerMsg = new Uint8Array(innerTagBytes.length + secondsBytes.length);
|
||||
innerMsg.set(innerTagBytes, 0);
|
||||
innerMsg.set(secondsBytes, innerTagBytes.length);
|
||||
|
||||
// Wrap in length delimited
|
||||
const tag = (fieldNum << 3) | 2;
|
||||
const tagBytes = this.encodeVarint(tag);
|
||||
const lenBytes = this.encodeVarint(innerMsg.length);
|
||||
|
||||
const result = new Uint8Array(tagBytes.length + lenBytes.length + innerMsg.length);
|
||||
result.set(tagBytes, 0);
|
||||
result.set(lenBytes, tagBytes.length);
|
||||
result.set(innerMsg, tagBytes.length + lenBytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract field by ID
|
||||
static getField(data: Uint8Array, fieldNum: number): Uint8Array | null {
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const { value: tag, nextOffset } = this.readVarint(data, offset);
|
||||
const wireType = Number(tag & 7n);
|
||||
const currentField = Number(tag >> 3n);
|
||||
|
||||
if (currentField === fieldNum) {
|
||||
if (wireType === 2) {
|
||||
// Length delimited
|
||||
const { value: length, nextOffset: dataStart } = this.readVarint(data, nextOffset);
|
||||
return data.slice(dataStart, dataStart + Number(length));
|
||||
}
|
||||
// For sync feature, we mainly care about wireType 2 (string/bytes)
|
||||
return null;
|
||||
}
|
||||
|
||||
offset = this.skipField(data, nextOffset, wireType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static readString(data: Uint8Array): string {
|
||||
const dec = new TextDecoder();
|
||||
return dec.decode(data);
|
||||
}
|
||||
|
||||
static extractOAuthTokenInfo(
|
||||
data: Uint8Array,
|
||||
): { accessToken: string; refreshToken: string } | null {
|
||||
// 1. Find Field 6 (OAuthTokenInfo)
|
||||
const field6Data = this.getField(data, 6);
|
||||
if (!field6Data) return null;
|
||||
|
||||
// 2. Parse Field 6 content
|
||||
// Field 1: Access Token (String)
|
||||
// Field 2: Type (String, "Bearer")
|
||||
// Field 3: Refresh Token (String)
|
||||
// Field 4: Expiry (Varint? Timestamp?) - In create we used createTimestampField which effectively makes it Field 4 -> Field 1.
|
||||
// But here we just need Access/Refresh.
|
||||
|
||||
const accessTokenBytes = this.getField(field6Data, 1);
|
||||
const refreshTokenBytes = this.getField(field6Data, 3);
|
||||
|
||||
if (accessTokenBytes && refreshTokenBytes) {
|
||||
return {
|
||||
accessToken: this.readString(accessTokenBytes),
|
||||
refreshToken: this.readString(refreshTokenBytes),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static createOAuthTokenInfo(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiry: number,
|
||||
): Uint8Array {
|
||||
const f1 = this.createStringField(1, accessToken);
|
||||
const f2 = this.createStringField(2, 'Bearer');
|
||||
const f3 = this.createStringField(3, refreshToken);
|
||||
const f4 = this.createTimestampField(4, expiry);
|
||||
|
||||
const combined = new Uint8Array(f1.length + f2.length + f3.length + f4.length);
|
||||
combined.set(f1, 0);
|
||||
combined.set(f2, f1.length);
|
||||
combined.set(f3, f1.length + f2.length);
|
||||
combined.set(f4, f1.length + f2.length + f3.length);
|
||||
|
||||
// Wrap as Field 6
|
||||
const tag6 = (6 << 3) | 2;
|
||||
const tag6Bytes = this.encodeVarint(tag6);
|
||||
const lenBytes = this.encodeVarint(combined.length);
|
||||
|
||||
const result = new Uint8Array(tag6Bytes.length + lenBytes.length + combined.length);
|
||||
result.set(tag6Bytes, 0);
|
||||
result.set(lenBytes, tag6Bytes.length);
|
||||
result.set(combined, tag6Bytes.length + lenBytes.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { routeTree } from "@/routeTree.gen";
|
||||
import { createMemoryHistory, createRouter } from "@tanstack/react-router";
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
defaultPendingMinMs: 0,
|
||||
routeTree,
|
||||
history: createMemoryHistory({
|
||||
initialEntries: ["/"],
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import keytar from 'keytar';
|
||||
import crypto from 'crypto';
|
||||
import { logger } from './logger';
|
||||
|
||||
const SERVICE_NAME = 'AntigravityManager';
|
||||
const ACCOUNT_NAME = 'MasterKey';
|
||||
|
||||
// Cache the key in memory to avoid frequent system calls
|
||||
let cachedMasterKey: Buffer | null = null;
|
||||
|
||||
async function getOrGenerateMasterKey(): Promise<Buffer> {
|
||||
if (cachedMasterKey) return cachedMasterKey;
|
||||
|
||||
try {
|
||||
let hexKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
||||
|
||||
if (!hexKey) {
|
||||
logger.info('Security: Generating new master key...');
|
||||
// Generate 256-bit key (32 bytes)
|
||||
const buffer = crypto.randomBytes(32);
|
||||
hexKey = buffer.toString('hex');
|
||||
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey);
|
||||
}
|
||||
|
||||
cachedMasterKey = Buffer.from(hexKey, 'hex');
|
||||
return cachedMasterKey;
|
||||
} catch (error) {
|
||||
logger.error('Security: Failed to access keychain/credential manager', error);
|
||||
// Fallback? If we can't store the key, we can't persistently encrypt.
|
||||
// For now, throw to prevent data loss (better not to write than to write something we can't decrypt later or write plain text when promised encrypted)
|
||||
throw new Error('Key management system unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Encrypts a string using AES-256-GCM.
|
||||
* Output format: "iv_hex:auth_tag_hex:ciphertext_hex"
|
||||
*/
|
||||
export async function encrypt(text: string): Promise<string> {
|
||||
try {
|
||||
const key = await getOrGenerateMasterKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:authTag:encrypted
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
} catch (error) {
|
||||
logger.error('Security: Encryption failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a string using AES-256-GCM.
|
||||
* Input format: "iv_hex:auth_tag_hex:ciphertext_hex"
|
||||
*/
|
||||
export async function decrypt(text: string): Promise<string> {
|
||||
// Check if it's plain text (JSON) for backward compatibility
|
||||
// Very rough check: starts with { or [
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Also checking if it follows our pattern
|
||||
const parts = text.split(':');
|
||||
if (parts.length !== 3) {
|
||||
// Treat as plain text if it doesn't look like our encrypted format
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await getOrGenerateMasterKey();
|
||||
|
||||
const [ivHex, authTagHex, encryptedHex] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
logger.error('Security: Decryption failed', error);
|
||||
// If decryption fails, it might be corrupted or using a different key.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user