feat: add access keys
This commit is contained in:
parent
86bf8bf03c
commit
fbd8e57ed6
@ -0,0 +1,34 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccessKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"machineId" TEXT NOT NULL,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"dataVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AccessKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccessKey_accountId_idx" ON "AccessKey"("accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccessKey_sessionId_idx" ON "AccessKey"("sessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccessKey_machineId_idx" ON "AccessKey"("machineId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccessKey_accountId_machineId_sessionId_key" ON "AccessKey"("accountId", "machineId", "sessionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccessKey" ADD CONSTRAINT "AccessKey_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccessKey" ADD CONSTRAINT "AccessKey_accountId_machineId_fkey" FOREIGN KEY ("accountId", "machineId") REFERENCES "Machine"("accountId", "id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccessKey" ADD CONSTRAINT "AccessKey_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -45,6 +45,7 @@ model Account {
|
|||||||
UploadedFile UploadedFile[]
|
UploadedFile UploadedFile[]
|
||||||
ServiceAccountToken ServiceAccountToken[]
|
ServiceAccountToken ServiceAccountToken[]
|
||||||
Artifact Artifact[]
|
Artifact Artifact[]
|
||||||
|
AccessKey AccessKey[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TerminalAuthRequest {
|
model TerminalAuthRequest {
|
||||||
@ -100,6 +101,7 @@ model Session {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
messages SessionMessage[]
|
messages SessionMessage[]
|
||||||
usageReports UsageReport[]
|
usageReports UsageReport[]
|
||||||
|
accessKeys AccessKey[]
|
||||||
|
|
||||||
@@unique([accountId, tag])
|
@@unique([accountId, tag])
|
||||||
@@index([accountId, updatedAt(sort: Desc)])
|
@@index([accountId, updatedAt(sort: Desc)])
|
||||||
@ -204,9 +206,10 @@ model Machine {
|
|||||||
dataEncryptionKey Bytes?
|
dataEncryptionKey Bytes?
|
||||||
seq Int @default(0)
|
seq Int @default(0)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
lastActiveAt DateTime @default(now())
|
lastActiveAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
accessKeys AccessKey[]
|
||||||
|
|
||||||
@@unique([accountId, id])
|
@@unique([accountId, id])
|
||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@ -263,3 +266,26 @@ model Artifact {
|
|||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@index([accountId, updatedAt(sort: Desc)])
|
@@index([accountId, updatedAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Access Keys
|
||||||
|
//
|
||||||
|
|
||||||
|
model AccessKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
accountId String
|
||||||
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
machineId String
|
||||||
|
machine Machine @relation(fields: [accountId, machineId], references: [accountId, id])
|
||||||
|
sessionId String
|
||||||
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
data String // Encrypted data
|
||||||
|
dataVersion Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([accountId, machineId, sessionId])
|
||||||
|
@@index([accountId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([machineId])
|
||||||
|
}
|
||||||
|
@ -15,6 +15,7 @@ import { devRoutes } from "./routes/devRoutes";
|
|||||||
import { versionRoutes } from "./routes/versionRoutes";
|
import { versionRoutes } from "./routes/versionRoutes";
|
||||||
import { voiceRoutes } from "./routes/voiceRoutes";
|
import { voiceRoutes } from "./routes/voiceRoutes";
|
||||||
import { artifactsRoutes } from "./routes/artifactsRoutes";
|
import { artifactsRoutes } from "./routes/artifactsRoutes";
|
||||||
|
import { accessKeysRoutes } from "./routes/accessKeysRoutes";
|
||||||
import { enableMonitoring } from "./utils/enableMonitoring";
|
import { enableMonitoring } from "./utils/enableMonitoring";
|
||||||
import { enableErrorHandlers } from "./utils/enableErrorHandlers";
|
import { enableErrorHandlers } from "./utils/enableErrorHandlers";
|
||||||
import { enableAuthentication } from "./utils/enableAuthentication";
|
import { enableAuthentication } from "./utils/enableAuthentication";
|
||||||
@ -56,6 +57,7 @@ export async function startApi(eventRouter: EventRouter) {
|
|||||||
connectRoutes(typed, eventRouter);
|
connectRoutes(typed, eventRouter);
|
||||||
machinesRoutes(typed, eventRouter);
|
machinesRoutes(typed, eventRouter);
|
||||||
artifactsRoutes(typed, eventRouter);
|
artifactsRoutes(typed, eventRouter);
|
||||||
|
accessKeysRoutes(typed, eventRouter);
|
||||||
devRoutes(typed);
|
devRoutes(typed);
|
||||||
versionRoutes(typed);
|
versionRoutes(typed);
|
||||||
voiceRoutes(typed);
|
voiceRoutes(typed);
|
||||||
|
290
sources/app/api/routes/accessKeysRoutes.ts
Normal file
290
sources/app/api/routes/accessKeysRoutes.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import { Fastify } from "../types";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/storage/db";
|
||||||
|
import { log } from "@/utils/log";
|
||||||
|
import { EventRouter } from "@/app/events/eventRouter";
|
||||||
|
|
||||||
|
export function accessKeysRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||||
|
// Get Access Key API
|
||||||
|
app.get('/v1/access-keys/:sessionId/:machineId', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
machineId: z.string()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
accessKey: z.object({
|
||||||
|
data: z.string(),
|
||||||
|
dataVersion: z.number(),
|
||||||
|
createdAt: z.number(),
|
||||||
|
updatedAt: z.number()
|
||||||
|
}).nullable()
|
||||||
|
}),
|
||||||
|
404: z.object({
|
||||||
|
error: z.literal('Session or machine not found')
|
||||||
|
}),
|
||||||
|
500: z.object({
|
||||||
|
error: z.literal('Failed to get access key')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { sessionId, machineId } = request.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify session and machine belong to user
|
||||||
|
const [session, machine] = await Promise.all([
|
||||||
|
db.session.findFirst({
|
||||||
|
where: { id: sessionId, accountId: userId }
|
||||||
|
}),
|
||||||
|
db.machine.findFirst({
|
||||||
|
where: { id: machineId, accountId: userId }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!session || !machine) {
|
||||||
|
return reply.code(404).send({ error: 'Session or machine not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access key
|
||||||
|
const accessKey = await db.accessKey.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_machineId_sessionId: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessKey) {
|
||||||
|
return reply.send({ accessKey: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
accessKey: {
|
||||||
|
data: accessKey.data,
|
||||||
|
dataVersion: accessKey.dataVersion,
|
||||||
|
createdAt: accessKey.createdAt.getTime(),
|
||||||
|
updatedAt: accessKey.updatedAt.getTime()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'api', level: 'error' }, `Failed to get access key: ${error}`);
|
||||||
|
return reply.code(500).send({ error: 'Failed to get access key' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Access Key API
|
||||||
|
app.post('/v1/access-keys/:sessionId/:machineId', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
machineId: z.string()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
data: z.string()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
accessKey: z.object({
|
||||||
|
data: z.string(),
|
||||||
|
dataVersion: z.number(),
|
||||||
|
createdAt: z.number(),
|
||||||
|
updatedAt: z.number()
|
||||||
|
}).optional(),
|
||||||
|
error: z.string().optional()
|
||||||
|
}),
|
||||||
|
404: z.object({
|
||||||
|
error: z.literal('Session or machine not found')
|
||||||
|
}),
|
||||||
|
409: z.object({
|
||||||
|
error: z.literal('Access key already exists')
|
||||||
|
}),
|
||||||
|
500: z.object({
|
||||||
|
error: z.literal('Failed to create access key')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { sessionId, machineId } = request.params;
|
||||||
|
const { data } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify session and machine belong to user
|
||||||
|
const [session, machine] = await Promise.all([
|
||||||
|
db.session.findFirst({
|
||||||
|
where: { id: sessionId, accountId: userId }
|
||||||
|
}),
|
||||||
|
db.machine.findFirst({
|
||||||
|
where: { id: machineId, accountId: userId }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!session || !machine) {
|
||||||
|
return reply.code(404).send({ error: 'Session or machine not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if access key already exists
|
||||||
|
const existing = await db.accessKey.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_machineId_sessionId: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(409).send({ error: 'Access key already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create access key
|
||||||
|
const accessKey = await db.accessKey.create({
|
||||||
|
data: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId,
|
||||||
|
data,
|
||||||
|
dataVersion: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log({ module: 'access-keys', userId, sessionId, machineId }, 'Created new access key');
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
accessKey: {
|
||||||
|
data: accessKey.data,
|
||||||
|
dataVersion: accessKey.dataVersion,
|
||||||
|
createdAt: accessKey.createdAt.getTime(),
|
||||||
|
updatedAt: accessKey.updatedAt.getTime()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'api', level: 'error' }, `Failed to create access key: ${error}`);
|
||||||
|
return reply.code(500).send({ error: 'Failed to create access key' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Access Key API
|
||||||
|
app.put('/v1/access-keys/:sessionId/:machineId', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
machineId: z.string()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
data: z.string(),
|
||||||
|
expectedVersion: z.number().int().min(0)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.union([
|
||||||
|
z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
version: z.number()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
error: z.literal('version-mismatch'),
|
||||||
|
currentVersion: z.number(),
|
||||||
|
currentData: z.string()
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
404: z.object({
|
||||||
|
error: z.literal('Access key not found')
|
||||||
|
}),
|
||||||
|
500: z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
error: z.literal('Failed to update access key')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { sessionId, machineId } = request.params;
|
||||||
|
const { data, expectedVersion } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current access key for version check
|
||||||
|
const currentAccessKey = await db.accessKey.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_machineId_sessionId: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentAccessKey) {
|
||||||
|
return reply.code(404).send({ error: 'Access key not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check version
|
||||||
|
if (currentAccessKey.dataVersion !== expectedVersion) {
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: false,
|
||||||
|
error: 'version-mismatch',
|
||||||
|
currentVersion: currentAccessKey.dataVersion,
|
||||||
|
currentData: currentAccessKey.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with version check
|
||||||
|
const { count } = await db.accessKey.updateMany({
|
||||||
|
where: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId,
|
||||||
|
dataVersion: expectedVersion
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
data,
|
||||||
|
dataVersion: expectedVersion + 1,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
// Re-fetch to get current version
|
||||||
|
const accessKey = await db.accessKey.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_machineId_sessionId: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: false,
|
||||||
|
error: 'version-mismatch',
|
||||||
|
currentVersion: accessKey?.dataVersion || 0,
|
||||||
|
currentData: accessKey?.data || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log({ module: 'access-keys', userId, sessionId, machineId }, `Updated access key to version ${expectedVersion + 1}`);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
version: expectedVersion + 1
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'api', level: 'error' }, `Failed to update access key: ${error}`);
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update access key'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -11,6 +11,7 @@ import { pingHandler } from "./socket/pingHandler";
|
|||||||
import { sessionUpdateHandler } from "./socket/sessionUpdateHandler";
|
import { sessionUpdateHandler } from "./socket/sessionUpdateHandler";
|
||||||
import { machineUpdateHandler } from "./socket/machineUpdateHandler";
|
import { machineUpdateHandler } from "./socket/machineUpdateHandler";
|
||||||
import { artifactUpdateHandler } from "./socket/artifactUpdateHandler";
|
import { artifactUpdateHandler } from "./socket/artifactUpdateHandler";
|
||||||
|
import { accessKeyHandler } from "./socket/accessKeyHandler";
|
||||||
|
|
||||||
export function startSocket(app: Fastify, eventRouter: EventRouter) {
|
export function startSocket(app: Fastify, eventRouter: EventRouter) {
|
||||||
const io = new Server(app.server, {
|
const io = new Server(app.server, {
|
||||||
@ -142,6 +143,7 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) {
|
|||||||
pingHandler(socket);
|
pingHandler(socket);
|
||||||
machineUpdateHandler(userId, socket, eventRouter);
|
machineUpdateHandler(userId, socket, eventRouter);
|
||||||
artifactUpdateHandler(userId, socket, eventRouter);
|
artifactUpdateHandler(userId, socket, eventRouter);
|
||||||
|
accessKeyHandler(userId, socket, eventRouter);
|
||||||
|
|
||||||
// Ready
|
// Ready
|
||||||
log({ module: 'websocket' }, `User connected: ${userId}`);
|
log({ module: 'websocket' }, `User connected: ${userId}`);
|
||||||
|
83
sources/app/api/socket/accessKeyHandler.ts
Normal file
83
sources/app/api/socket/accessKeyHandler.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { db } from "@/storage/db";
|
||||||
|
import { log } from "@/utils/log";
|
||||||
|
import { EventRouter } from "@/app/events/eventRouter";
|
||||||
|
|
||||||
|
export function accessKeyHandler(userId: string, socket: Socket, eventRouter: EventRouter) {
|
||||||
|
// Get access key via socket
|
||||||
|
socket.on('access-key-get', async (data: { sessionId: string; machineId: string }, callback: (response: any) => void) => {
|
||||||
|
try {
|
||||||
|
const { sessionId, machineId } = data;
|
||||||
|
|
||||||
|
if (!sessionId || !machineId) {
|
||||||
|
if (callback) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
error: 'Invalid parameters: sessionId and machineId are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session and machine belong to user
|
||||||
|
const [session, machine] = await Promise.all([
|
||||||
|
db.session.findFirst({
|
||||||
|
where: { id: sessionId, accountId: userId }
|
||||||
|
}),
|
||||||
|
db.machine.findFirst({
|
||||||
|
where: { id: machineId, accountId: userId }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!session || !machine) {
|
||||||
|
if (callback) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
error: 'Session or machine not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access key
|
||||||
|
const accessKey = await db.accessKey.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_machineId_sessionId: {
|
||||||
|
accountId: userId,
|
||||||
|
machineId,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
if (accessKey) {
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
accessKey: {
|
||||||
|
data: accessKey.data,
|
||||||
|
dataVersion: accessKey.dataVersion,
|
||||||
|
createdAt: accessKey.createdAt.getTime(),
|
||||||
|
updatedAt: accessKey.updatedAt.getTime()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
accessKey: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log({ module: 'websocket-access-key' }, `Access key retrieved for session ${sessionId}, machine ${machineId}`);
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'websocket', level: 'error' }, `Error in access-key-get: ${error}`);
|
||||||
|
if (callback) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
error: 'Internal error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user