Skip to content

Commit 12b69c2

Browse files
Prevent deletion of billing info, save old billing info just in case. Ticketing adjusts.. Passkeys fix.
1 parent 91b2b5a commit 12b69c2

36 files changed

Lines changed: 2685 additions & 183 deletions

backend/bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/config/index.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,6 @@ export async function setupConfig(app: any) {
102102
app.log?.warn({ err }, 'Failed to ensure soc_data(serverId,timestamp) index');
103103
}
104104

105-
if (['mysql', 'mariadb'].includes(String(AppDataSource.options.type))) {
106-
try {
107-
await AppDataSource.query('ALTER TABLE `ticket` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
108-
await AppDataSource.query('ALTER TABLE `ticket` MODIFY `adminReply` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL');
109-
await AppDataSource.query('ALTER TABLE `ticket` MODIFY `message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL');
110-
await AppDataSource.query('ALTER TABLE `ticket` MODIFY `messages` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL');
111-
} catch (err: any) {
112-
app.log?.warn({ err }, 'Failed to convert ticket table charset; existing charset may still be non-utf8mb4');
113-
}
114-
}
115-
116105
try {
117106
await connectRedis();
118107
(app.log ?? console).info('Redis connected');

backend/src/config/typeorm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export const AppDataSource = new DataSource({
138138
require('../models/notification.entity').Notification,
139139
require('../models/mailMessage.entity').MailMessage,
140140
require('../models/outboundEmail.entity').OutboundEmail,
141+
require('../models/parentLinkRequest.entity').ParentLinkRequest,
142+
require('../models/parentRegistrationInvite.entity').ParentRegistrationInvite,
141143
require('../models/adminBroadcastJob.entity').AdminBroadcastJob,
142144
require('../models/sshKey.entity').SshKey,
143145
require('../models/organisationDnsZone.entity').OrganisationDnsZone,

backend/src/handlers/adminHandler.ts

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function getSafeRelativeFilePath(base: string, relPath: string): string | null {
5050
import { SocData } from '../models/socData.entity';
5151
import { ApplicationForm } from '../models/applicationForm.entity';
5252
import { ApplicationSubmission } from '../models/applicationSubmission.entity';
53-
import { notifyServerOwnerSuspended, notifyServerOwnerUnsuspended } from '../utils/suspensionNotice';
53+
import { notifyServerOwnerDmca, notifyServerOwnerSuspended, notifyServerOwnerUnsuspended } from '../utils/suspensionNotice';
5454
import { createActivityLog } from './logHandler';
5555

5656
const adminRoles = ['admin', 'rootAdmin', '*'];
@@ -332,6 +332,41 @@ function sanitizeForDb(s: string | null | undefined) {
332332
}
333333
}
334334

335+
function normalizeTicketMessages(ticket: Ticket) {
336+
if (!ticket) return;
337+
if (Array.isArray(ticket.messages)) return;
338+
339+
try {
340+
if (typeof ticket.messages === 'string') {
341+
const parsed = JSON.parse(ticket.messages);
342+
if (Array.isArray(parsed)) {
343+
ticket.messages = parsed;
344+
return;
345+
}
346+
}
347+
348+
if (ticket.messages && typeof ticket.messages === 'object') {
349+
if (Array.isArray((ticket.messages as any).messages)) {
350+
ticket.messages = (ticket.messages as any).messages;
351+
return;
352+
}
353+
354+
const keys = Object.keys(ticket.messages);
355+
const numericKeys = keys.filter((k) => /^\d+$/.test(k));
356+
if (numericKeys.length === keys.length && numericKeys.length > 0) {
357+
ticket.messages = numericKeys
358+
.sort((a, b) => Number(a) - Number(b))
359+
.map((k) => (ticket.messages as any)[k]);
360+
return;
361+
}
362+
}
363+
} catch {
364+
// skippy
365+
}
366+
367+
ticket.messages = [];
368+
}
369+
335370
function getTicketResponseDurations(ticket: Ticket): number[] {
336371
const records = Array.isArray(ticket.messages) ? ticket.messages : [];
337372
const sorted = records
@@ -1077,6 +1112,12 @@ export async function adminRoutes(app: any, prefix = '') {
10771112
ctx.set.status = 400;
10781113
return { error: 'invalid_date_of_birth', message: 'dateOfBirth must be a valid date string in YYYY-MM-DD format.' };
10791114
}
1115+
const updatedAge = getAgeFromDate(dob);
1116+
if (updatedAge !== null && updatedAge < 14) {
1117+
user.suspended = true;
1118+
user.fraudFlag = true;
1119+
user.fraudReason = 'Underage account (<14 years)';
1120+
}
10801121
user.dateOfBirth = dob;
10811122
}
10821123
if (parentId !== undefined) {
@@ -1390,6 +1431,7 @@ export async function adminRoutes(app: any, prefix = '') {
13901431
const nextStatus = normalizeTicketStatus(status);
13911432
if (nextStatus !== ticket.status) {
13921433
ticket.status = nextStatus;
1434+
normalizeTicketMessages(ticket);
13931435
if (!Array.isArray(ticket.messages)) ticket.messages = [];
13941436
ticket.messages.push({
13951437
sender: 'staff',
@@ -1399,6 +1441,7 @@ export async function adminRoutes(app: any, prefix = '') {
13991441
}
14001442
}
14011443

1444+
normalizeTicketMessages(ticket);
14021445
if (!Array.isArray(ticket.messages)) ticket.messages = [];
14031446

14041447
if (typeof reply === 'string' && reply.trim()) {
@@ -2583,12 +2626,17 @@ export async function adminRoutes(app: any, prefix = '') {
25832626
if (!requireAdminCtx(ctx)) return;
25842627
const serverId = ctx.params.id as string;
25852628
const body = (ctx.body || {}) as any;
2629+
const dmcaMark = Boolean(body.dmca);
25862630
const reason = typeof body.reason === 'string' && body.reason.trim()
25872631
? body.reason.trim()
2588-
: 'Suspended by administrator';
2632+
: dmcaMark
2633+
? 'DMCA takedown'
2634+
: 'Suspended by administrator';
25892635
const adminUser = ctx.user as any;
25902636
const adminName = [adminUser?.firstName, adminUser?.lastName].filter(Boolean).join(' ').trim();
25912637
const suspendedBy = adminName || adminUser?.email || 'admin panel';
2638+
const dmcaAt = dmcaMark ? new Date() : undefined;
2639+
const dmcaDeletionAt = dmcaMark ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined;
25922640

25932641
const nodeRepo = AppDataSource.getRepository(Node);
25942642
const cfgRepo = AppDataSource.getRepository(require('../models/serverConfig.entity').ServerConfig);
@@ -2605,9 +2653,23 @@ export async function adminRoutes(app: any, prefix = '') {
26052653
const svc = new WingsApiService(base, n.token);
26062654
await svc.getServer(serverId);
26072655
const alreadySuspended = !!existingCfg?.suspended;
2656+
const alreadyDmca = !!existingCfg?.dmca;
2657+
const updateData: any = {
2658+
suspended: true,
2659+
suspendedBy,
2660+
suspendedReason: reason,
2661+
suspendedAt: new Date(),
2662+
};
2663+
if (dmcaMark) {
2664+
updateData.dmca = true;
2665+
updateData.dmcaBy = suspendedBy;
2666+
updateData.dmcaReason = reason;
2667+
updateData.dmcaAt = dmcaAt;
2668+
updateData.dmcaDeletionAt = dmcaDeletionAt;
2669+
}
26082670
await cfgRepo.update(
26092671
{ uuid: serverId },
2610-
{ suspended: true, suspendedBy, suspendedReason: reason, suspendedAt: new Date() },
2672+
updateData,
26112673
);
26122674
await svc.powerServer(serverId, 'kill').catch(() => { });
26132675
await svc.syncServer(serverId, {});
@@ -2624,7 +2686,19 @@ export async function adminRoutes(app: any, prefix = '') {
26242686
recipient: undefined,
26252687
};
26262688

2627-
if (!alreadySuspended && existingCfg) {
2689+
const shouldSendDmcaNotice = dmcaMark && !alreadyDmca;
2690+
if (shouldSendDmcaNotice && existingCfg) {
2691+
notice = await notifyServerOwnerDmca({
2692+
cfg: existingCfg,
2693+
actor: suspendedBy,
2694+
reason,
2695+
dmcaAt,
2696+
deletionAt: dmcaDeletionAt,
2697+
});
2698+
if (!notice.sent && !notice.skipped) {
2699+
console.warn('[admin:server:suspend] failed to notify owner by email:', notice.reason || 'unknown error');
2700+
}
2701+
} else if (!alreadySuspended && !dmcaMark && existingCfg) {
26282702
notice = await notifyServerOwnerSuspended({
26292703
cfg: existingCfg,
26302704
actor: suspendedBy,
@@ -2634,8 +2708,10 @@ export async function adminRoutes(app: any, prefix = '') {
26342708
if (!notice.sent && !notice.skipped) {
26352709
console.warn('[admin:server:suspend] failed to notify owner by email:', notice.reason || 'unknown error');
26362710
}
2637-
} else if (alreadySuspended) {
2711+
} else if (alreadySuspended && !dmcaMark) {
26382712
notice.reason = 'server already suspended';
2713+
} else if (alreadyDmca && dmcaMark) {
2714+
notice.reason = 'server already marked DMCA';
26392715
}
26402716

26412717
return {
@@ -2653,7 +2729,7 @@ export async function adminRoutes(app: any, prefix = '') {
26532729
beforeHandle: authenticate,
26542730
schema: {
26552731
params: t.Object({ id: t.String() }),
2656-
body: t.Optional(t.Object({ reason: t.Optional(t.String()) })),
2732+
body: t.Optional(t.Object({ reason: t.Optional(t.String()), dmca: t.Optional(t.Boolean()) })),
26572733
response: {
26582734
200: t.Object({
26592735
success: t.Boolean(),
@@ -3649,7 +3725,7 @@ export async function adminRoutes(app: any, prefix = '') {
36493725
const apiKeys = await apiKeyRepo.find({ where: { user: { id: userId } } });
36503726
const idVerifications = await idVerificationRepo.find({ where: { userId } });
36513727
const tickets = await ticketRepo.find({ where: { userId } });
3652-
const userLogs = await userLogRepo.find({ where: { userId } });
3728+
const userLogs = await userLogRepo.find({ where: { userId }, order: { timestamp: 'DESC' }, take: 10 });
36533729
const organisationsOwned = await organisationRepo.find({ where: { ownerId: userId }, relations: ['invites'] });
36543730
const membershipRows = await orgMemberRepo.find({ where: { userId }, relations: ['organisation'] });
36553731
const organisations = membershipRows
@@ -3925,6 +4001,28 @@ export async function adminRoutes(app: any, prefix = '') {
39254001
detail: { summary: 'Export all user and owned object data (admin)', tags: ['Admin'] },
39264002
});
39274003

4004+
app.get(prefix + '/admin/users/:id/address-change-logs', async (ctx: any) => {
4005+
if (!requireAdminCtx(ctx)) return;
4006+
const userId = Number(ctx.params.id);
4007+
const userRepo = AppDataSource.getRepository(User);
4008+
const user = await userRepo.findOneBy({ id: userId });
4009+
if (!user) {
4010+
ctx.set.status = 404;
4011+
return { error: 'User not found' };
4012+
}
4013+
4014+
const userLogRepo = AppDataSource.getRepository(UserLog);
4015+
const logs = await userLogRepo.find({ where: { userId, action: 'update-address' }, order: { timestamp: 'DESC' }, take: 10 });
4016+
return { success: true, logs };
4017+
}, {
4018+
beforeHandle: authenticate,
4019+
schema: {
4020+
params: t.Object({ id: t.String() }),
4021+
response: { 200: t.Object({ success: t.Boolean(), logs: t.Array(t.Any()) }), 401: t.Object({ error: t.String() }), 403: t.Object({ error: t.String() }), 404: t.Object({ error: t.String() }) },
4022+
},
4023+
detail: { summary: 'Get last address change logs for a user', tags: ['Admin'] },
4024+
});
4025+
39284026
app.delete('/admin/users/:id/ai/:linkId', async (ctx) => {
39294027
if (!requireAdminCtx(ctx)) return;
39304028
const AIModelUser = require('../models/aiModelUser.entity').AIModelUser;
@@ -4481,6 +4579,7 @@ isSuspicious: true if fraudScore >= 50`;
44814579
portalDescriptions: portalDescriptions || null,
44824580
codeInstancesEnabled,
44834581
geoBlockCountries: map['geoBlockCountries'] || '',
4582+
countryAgeRules: map['countryAgeRules'] || '',
44844583
billingCurrency: (map['billingCurrency'] || 'USD').toUpperCase(),
44854584
billingTaxRules: map['billingTaxRules'] || '',
44864585
gamblingEnabled: gamblingConfig.gamblingEnabled,
@@ -4497,6 +4596,7 @@ isSuspicious: true if fraudScore >= 50`;
44974596
codeInstancesEnabled: t.Boolean(),
44984597
featureToggles: t.Record(t.String(), t.Boolean()),
44994598
geoBlockCountries: t.String(),
4599+
countryAgeRules: t.Optional(t.String()),
45004600
billingCurrency: t.String(),
45014601
billingTaxRules: t.String(),
45024602
gamblingEnabled: t.Boolean(),
@@ -4535,6 +4635,7 @@ isSuspicious: true if fraudScore >= 50`;
45354635
codeInstancesEnabled,
45364636
portalDescriptions: portalDescriptions || null,
45374637
geoBlockCountries: map['geoBlockCountries'] || '',
4638+
countryAgeRules: map['countryAgeRules'] || '',
45384639
billingCurrency: (map['billingCurrency'] || 'USD').toUpperCase(),
45394640
billingTaxRules: map['billingTaxRules'] || '',
45404641
gamblingEnabled: gamblingConfig.gamblingEnabled,
@@ -4551,6 +4652,7 @@ isSuspicious: true if fraudScore >= 50`;
45514652
codeInstancesEnabled: t.Boolean(),
45524653
portalDescriptions: t.Optional(t.Any()),
45534654
geoBlockCountries: t.String(),
4655+
countryAgeRules: t.Optional(t.String()),
45544656
billingCurrency: t.String(),
45554657
billingTaxRules: t.String(),
45564658
gamblingEnabled: t.Boolean(),
@@ -4649,7 +4751,7 @@ isSuspicious: true if fraudScore >= 50`;
46494751
if (!requireAdminCtx(ctx)) return;
46504752
const repo = AppDataSource.getRepository(PanelSetting);
46514753
const body = ctx.body as any;
4652-
const allowed = ['registrationEnabled', 'registrationNotice', 'codeInstancesEnabled', 'geoBlockCountries', 'billingCurrency', 'billingTaxRules', 'gamblingEnabled', 'gamblingResourceLuckyChance', 'gamblingPowerDenyChance'];
4754+
const allowed = ['registrationEnabled', 'registrationNotice', 'codeInstancesEnabled', 'geoBlockCountries', 'countryAgeRules', 'billingCurrency', 'billingTaxRules', 'gamblingEnabled', 'gamblingResourceLuckyChance', 'gamblingPowerDenyChance'];
46534755
for (const key of allowed) {
46544756
if (body[key] !== undefined) {
46554757
let value = typeof body[key] === 'boolean' ? String(body[key]) : String(body[key]);
@@ -4689,6 +4791,7 @@ isSuspicious: true if fraudScore >= 50`;
46894791
portalDescriptions: portalDescriptions || null,
46904792
codeInstancesEnabled: map['codeInstancesEnabled'] !== 'false',
46914793
geoBlockCountries: map['geoBlockCountries'] || '',
4794+
countryAgeRules: map['countryAgeRules'] || '',
46924795
billingCurrency: (map['billingCurrency'] || 'USD').toUpperCase(),
46934796
billingTaxRules: map['billingTaxRules'] || '',
46944797
gamblingEnabled: gamblingConfig.gamblingEnabled,
@@ -4708,6 +4811,7 @@ isSuspicious: true if fraudScore >= 50`;
47084811
geoBlockCountries: t.Optional(t.String()),
47094812
billingCurrency: t.Optional(t.String()),
47104813
billingTaxRules: t.Optional(t.String()),
4814+
countryAgeRules: t.Optional(t.String()),
47114815
gamblingEnabled: t.Optional(t.Boolean()),
47124816
gamblingResourceLuckyChance: t.Optional(t.Number()),
47134817
gamblingPowerDenyChance: t.Optional(t.Number()),

backend/src/handlers/authHandler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,10 +800,14 @@ export async function authRoutes(app: any, prefix = '') {
800800
ctx.set.status = 400;
801801
return { error: 'No challenge' };
802802
}
803+
const requestOrigin = ctx.headers?.origin || ctx.headers?.referer || ctx.headers?.Referrer;
804+
const requestHost = getFrontendHost(ctx);
803805
const ver = await PasskeyService.verifyRegistrationResponse({
804806
userId: user.id,
805807
attestationResponse,
806808
expectedChallenge: String(expected),
809+
requestHost,
810+
requestOrigin,
807811
});
808812
await redisDel(`passkey:reg:${user.id}`);
809813
return ver;
@@ -861,10 +865,14 @@ export async function authRoutes(app: any, prefix = '') {
861865
ctx.set.status = 400;
862866
return { error: 'No challenge' };
863867
}
868+
const requestOrigin = ctx.headers?.origin || ctx.headers?.referer || ctx.headers?.Referrer;
869+
const requestHost = getFrontendHost(ctx);
864870
const ver = await PasskeyService.verifyAuthenticationResponse({
865871
userId: user.id,
866872
authenticationResponse,
867873
expectedChallenge: String(expected),
874+
requestHost,
875+
requestOrigin,
868876
});
869877
if (ver.verified) {
870878
const sessionId = uuidv4();

0 commit comments

Comments
 (0)