Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dependencies": {
"@tailwindcss/vite": "^4.1.13",
"axios": "^1.12.2",
"bad-words": "^4.0.0",
"chalk": "^5.6.2",
"chart.js": "^4.5.0",
"concurrently": "^9.2.1",
Expand Down
Binary file added public/assets/images/automod.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 92 additions & 2 deletions server/db/chats.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { chatsDb } from "./connection.js";
import { encrypt, decrypt } from "../utils/encryption.js";
import { validateSessionId } from "../utils/validation.js";
import { sql } from "kysely";
import { incrementStat } from "../utils/statisticsCache.js";
import { mainDb } from './connection.js';
import { Filter } from 'bad-words';
import { sql } from "kysely";

const filter = new Filter();

export async function ensureChatTable(sessionId: string) {
const validSessionId = validateSessionId(sessionId);
Expand Down Expand Up @@ -41,12 +45,32 @@ export async function addChatMessage(sessionId: string, { userId, username, avat
username,
avatar,
message: JSON.stringify(encryptedMsg),
mentions: JSON.stringify(mentions)
mentions: mentions.length > 0 ? JSON.stringify(mentions) : undefined
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the mentions field to conditionally store undefined instead of an empty array JSON string ('[]') may cause issues in code that expects a consistent JSON string format. This could break existing message retrieval or parsing logic.

Suggested change
mentions: mentions.length > 0 ? JSON.stringify(mentions) : undefined
mentions: JSON.stringify(mentions)

Copilot uses AI. Check for mistakes.
})
.returningAll()
.executeTakeFirst();

incrementStat(userId, 'total_chat_messages_sent');

if (filter.isProfane(message)) {
await mainDb
.insertInto('chat_report')
.values({
id: sql`DEFAULT`,
session_id: validSessionId,
message_id: result!.id,
reporter_user_id: 'automod',
reporter_username: 'Automod',
reported_user_id: userId,
reported_username: username,
reported_avatar: avatar || '/assets/app/default/avatar.webp',
message,
reason: 'Inappropriate language (automod)',
avatar: '/assets/images/automod.webp',
})
.execute();
}

return { ...result, message, mentions };
}

Expand Down Expand Up @@ -93,4 +117,70 @@ export async function deleteChatMessage(sessionId: string, messageId: number, us
.where('user_id', '=', userId)
.executeTakeFirst();
return (result?.numDeletedRows ?? 0) > 0;
}

export async function reportChatMessage(sessionId: string, messageId: number, reporterUserId: string, reason: string) {
const validSessionId = validateSessionId(sessionId);
await ensureChatTable(validSessionId);
const tableName = `chat_${validSessionId}`;

const messageRow = await chatsDb
.selectFrom(tableName)
.select(['user_id', 'message'])
.where('id', '=', messageId)
.executeTakeFirst();

if (!messageRow) {
throw new Error('Message not found');
}

let plainMessage = '';
try {
plainMessage = decrypt(JSON.parse(messageRow.message));
} catch {
plainMessage = '';
}

let reporterUsername = '';
let reporterAvatar = '/assets/images/automod.webp';
if (reporterUserId !== 'automod') {
const reporter = await mainDb
.selectFrom('users')
.select(['username', 'avatar'])
.where('id', '=', reporterUserId)
.executeTakeFirst();
reporterUsername = reporter?.username || '';
reporterAvatar = reporter?.avatar
? (reporter.avatar.startsWith('http') ? reporter.avatar : `https://cdn.discordapp.com/avatars/${reporterUserId}/${reporter.avatar}.png`)
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Complex nested ternary expressions make the code difficult to read and maintain. Consider extracting avatar URL construction into a separate helper function.

Copilot uses AI. Check for mistakes.
: '/assets/app/default/avatar.webp';
}

let reportedUsername = '';
let reportedAvatar = '/assets/app/default/avatar.webp';
const reportedUser = await mainDb
.selectFrom('users')
.select(['username', 'avatar'])
.where('id', '=', messageRow.user_id)
.executeTakeFirst();
reportedUsername = reportedUser?.username || '';
reportedAvatar = reportedUser?.avatar
? (reportedUser.avatar.startsWith('http') ? reportedUser.avatar : `https://cdn.discordapp.com/avatars/${messageRow.user_id}/${reportedUser.avatar}.png`)
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Complex nested ternary expressions make the code difficult to read and maintain. Consider extracting avatar URL construction into a separate helper function.

Copilot uses AI. Check for mistakes.
: '/assets/app/default/avatar.webp';

await mainDb
.insertInto('chat_report')
.values({
id: sql`DEFAULT`,
session_id: validSessionId,
message_id: messageId,
reporter_user_id: reporterUserId,
reporter_username: reporterUsername,
reported_user_id: messageRow.user_id,
reported_username: reportedUsername,
reported_avatar: reportedAvatar,
message: plainMessage,
reason,
avatar: reporterAvatar,
})
.execute();
}
19 changes: 19 additions & 0 deletions server/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ export async function createMainTables() {
.addColumn('flights_count', 'integer', (col) => col.defaultTo(0))
.addColumn('users_count', 'integer', (col) => col.defaultTo(0))
.execute();

// chat_report
await mainDb.schema
.createTable('chat_report')
.ifNotExists()
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('session_id', 'varchar(255)', (col) => col.notNull())
.addColumn('message_id', 'integer', (col) => col.notNull())
.addColumn('reporter_user_id', 'varchar(255)', (col) => col.notNull())
.addColumn('reporter_username', 'varchar(255)')
.addColumn('reported_user_id', 'varchar(255)', (col) => col.notNull())
.addColumn('reported_username', 'varchar(255)')
.addColumn('reported_avatar', 'varchar(255)')
.addColumn('message', 'text', (col) => col.notNull())
.addColumn('reason', 'text', (col) => col.notNull())
.addColumn('timestamp', 'timestamp', (col) => col.defaultTo('now()'))
.addColumn('status', 'varchar(50)', (col) => col.defaultTo('pending'))
.addColumn('avatar', 'varchar(255)')
.execute();
}

// Helper to create a dynamic flights table for a session
Expand Down
2 changes: 2 additions & 0 deletions server/db/types/connection/MainDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { UserNotificationsTable } from "./main/UserNotificationsTable";
import { TestersTable } from "./main/TestersTable";
import { TesterSettingsTable } from "./main/TesterSettingsTable";
import { DailyStatisticsTable } from "./main/DailyStatisticsTable";
import { ChatReportsTable } from "./main/ChatReportsTable";

export interface MainDatabase {
app_settings: AppSettingsTable;
Expand All @@ -24,4 +25,5 @@ export interface MainDatabase {
testers: TestersTable;
tester_settings: TesterSettingsTable;
daily_statistics: DailyStatisticsTable;
chat_report: ChatReportsTable;
}
15 changes: 15 additions & 0 deletions server/db/types/connection/main/ChatReportsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface ChatReportsTable {
id: number;
session_id: string;
message_id: number;
reporter_user_id: string;
reported_user_id: string;
message: string;
reason: string;
timestamp?: Date;
status?: 'pending' | 'resolved';
avatar?: string;
reported_username?: string;
reporter_username?: string;
reported_avatar?: string;
}
2 changes: 1 addition & 1 deletion server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ app.use(cors({
'http://localhost:5173',
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin', 'Access-Control-Allow-Credentials']
}));
app.use(cookieParser());
Expand Down
141 changes: 141 additions & 0 deletions server/routes/admin/chat-reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import express from 'express';
import { mainDb } from '../../db/connection.js';
import requireAuth from '../../middleware/auth.js';
import { logAdminAction } from '../../db/audit.js';

const router = express.Router();

// GET: /api/admin/chat-reports
router.get('/', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user?.isAdmin) {
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission check only validates isAdmin but doesn't check for the 'chat_reports' permission that was added to the permission system. This is inconsistent with the frontend permission check and could allow unauthorized access if admin privileges are separated from specific permissions.

Suggested change
if (!user?.isAdmin) {
if (!user?.isAdmin || !user?.permissions?.includes?.('chat_reports')) {

Copilot uses AI. Check for mistakes.
return res.status(403).json({ error: 'Forbidden' });
}

await logAdminAction({
adminId: user.userId,
adminUsername: user.username,
actionType: 'CHAT_REPORTS_ACCESSED',
details: {
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 50,
filterReporter: req.query.reporter as string
}
});

const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
const offset = (page - 1) * limit;
const filterReporter = req.query.reporter as string;

let totalQuery = mainDb.selectFrom('chat_report');
if (filterReporter) {
totalQuery = totalQuery.where('reporter_user_id', '=', filterReporter);
}
const total = await totalQuery.select(({ fn }) => fn.count('id').as('count')).executeTakeFirst();

let query = mainDb.selectFrom('chat_report').selectAll().orderBy('timestamp', 'desc');
if (filterReporter) {
query = query.where('reporter_user_id', '=', filterReporter);
}
const reports = await query.limit(limit).offset(offset).execute();

res.json({
reports,
pagination: {
page,
limit,
total: Number(total?.count || 0),
pages: Math.ceil(Number(total?.count || 0) / limit),
},
});
} catch (error) {
console.error('Error fetching chat reports:', error);
res.status(500).json({ error: 'Failed to fetch chat reports' });
}
});

// PATCH: /api/admin/chat-reports/:id
router.patch('/:id', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user?.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
Comment on lines +63 to +65
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission check only validates isAdmin but doesn't check for the 'chat_reports' permission that was added to the permission system. This is inconsistent with the frontend permission check and could allow unauthorized access if admin privileges are separated from specific permissions.

Copilot uses AI. Check for mistakes.

const { status } = req.body;
if (!['pending', 'resolved'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}

const report = await mainDb
.selectFrom('chat_report')
.select(['reported_user_id'])
.where('id', '=', parseInt(req.params.id))
.executeTakeFirst();

if (!report) {
return res.status(404).json({ error: 'Report not found' });
}

await mainDb
.updateTable('chat_report')
.set({ status })
.where('id', '=', parseInt(req.params.id))
.execute();

await logAdminAction({
adminId: user.userId,
adminUsername: user.username,
actionType: 'CHAT_REPORT_STATUS_UPDATED',
targetUserId: report.reported_user_id,
details: { reportId: req.params.id, status }
});

res.json({ success: true });
} catch (error) {
console.error('Error updating report:', error);
res.status(500).json({ error: 'Failed to update report' });
}
});

// DELETE: /api/admin/chat-reports/:id
router.delete('/:id', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user?.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
Comment on lines +107 to +109
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission check only validates isAdmin but doesn't check for the 'chat_reports' permission that was added to the permission system. This is inconsistent with the frontend permission check and could allow unauthorized access if admin privileges are separated from specific permissions.

Copilot uses AI. Check for mistakes.

const report = await mainDb
.selectFrom('chat_report')
.select(['reported_user_id'])
.where('id', '=', parseInt(req.params.id))
.executeTakeFirst();

if (!report) {
return res.status(404).json({ error: 'Report not found' });
}

await mainDb
.deleteFrom('chat_report')
.where('id', '=', parseInt(req.params.id))
.execute();

await logAdminAction({
adminId: user.userId,
adminUsername: user.username,
actionType: 'CHAT_REPORT_DELETED',
targetUserId: report.reported_user_id,
details: { reportId: req.params.id }
});

res.json({ success: true });
} catch (error) {
console.error('Error deleting report:', error);
res.status(500).json({ error: 'Failed to delete report' });
}
});

export default router;
2 changes: 2 additions & 0 deletions server/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import bansRouter from './ban.js';
import testersRouter from './testers.js';
import notificationRouter from './notifications.js';
import rolesRouter from './roles.js';
import chatReportsRouter from './chat-reports.js';

const router = express.Router();

Expand All @@ -25,6 +26,7 @@ router.use('/bans', bansRouter);
router.use('/testers', testersRouter);
router.use('/notifications', notificationRouter);
router.use('/roles', rolesRouter);
router.use('/chat-reports', chatReportsRouter);

// GET: /api/admin/statistics - Get dashboard statistics
router.get('/statistics', requirePermission('admin'), async (req, res) => {
Expand Down
Loading
Loading