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
76 changes: 76 additions & 0 deletions .github/scripts/validate-translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { sync as globSync } from 'fast-glob';
import { readFileSync } from 'fs';
import { resolve } from 'path';

const KEY_REGEX = /(['`])\$\.(\w+(?:\.\w+)+)(?=;;|\1|$)/g;
const LOCALE_FILE = resolve('locales/en_us.json');
const SRC_FILES = globSync(['src/**/*.ts'], { dot: false });

type LocaleObject = Record<string, any>;

const localeData: LocaleObject = JSON.parse(readFileSync(LOCALE_FILE, 'utf-8'));
const flatLocaleKeys = flattenKeys(localeData);

const usedKeysMap: Map<string, Set<string>> = new Map();

for (const file of SRC_FILES) {
const content = readFileSync(file, 'utf-8');
let match: RegExpExecArray | null;

while ((match = KEY_REGEX.exec(content)) !== null) {
const key = match[2];
if (!usedKeysMap.has(key)) {
usedKeysMap.set(key, new Set());
}
usedKeysMap.get(key)!.add(file);
}
}

const usedKeys = [...usedKeysMap.keys()];
const missingKeys = usedKeys.filter((key) => !flatLocaleKeys.has(key));
const unusedKeys = [...flatLocaleKeys].filter((key) => !usedKeysMap.has(key));

console.log('\n🔍 Localization Check Results:\n');

if (missingKeys.length) {
console.log('❌ Missing keys in en_us.json:');
for (const key of missingKeys) {
const files = [...(usedKeysMap.get(key) ?? [])];
for (const file of files) {
console.log(` - ${key} (${file})`);
}
}
} else {
console.log('✅ No missing keys.');
}

if (unusedKeys.length) {
console.log('\n⚠️ Unused keys in en_us.json:');
for (const key of unusedKeys) {
console.log(` - ${key}`);
}
} else {
console.log('✅ No unused keys.');
}

if (missingKeys.length || unusedKeys.length) {
process.exit(1);
}

function flattenKeys(obj: LocaleObject, prefix = ''): Set<string> {
const keys = new Set<string>();

for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;

if (typeof obj[key] === 'object' && obj[key] !== null) {
for (const subKey of flattenKeys(obj[key], fullKey)) {
keys.add(subKey);
}
} else {
keys.add(fullKey);
}
}

return keys;
}
29 changes: 29 additions & 0 deletions .github/workflows/validate-translations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Validate Localization Keys

on:
push:
paths:
- 'src/**/*.ts'
- 'locales/en_us.json'
- '.github/scripts/validate-translations.ts'
- '.github/workflows/validate-translations.yml'
pull_request:

jobs:
validate-translations:
runs-on: ubuntu-latest

steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Set up bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun i fast-glob

- name: Run localization key check
run: bun .github/scripts/validate-translations.ts
4 changes: 2 additions & 2 deletions locales/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"noTag": "Please set a tag first!",
"playerNoTag": "This player does not have a tag!",
"playerBanned": "This player is banned!",
"malformedAuthHeader": "You've entered a malformed authorization header!",
"noIcon": "The icon was not found!"
"noIcon": "The icon was not found!",
"invalid_bitfield": "You provided an invalid bitfield!"
},
"gift_codes": {
"not_found": "Gift code not found!",
Expand Down
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const elysia = new Elysia()
if(code == 'VALIDATION') {
set.status = 422;
error = error as ValidationError;
let errorMessage = i18n(error.message);
let errorMessage = error.message;
const errorParts = errorMessage.split(';;');
errorMessage = i18n(errorParts[0]);
if(errorParts.length > 1) {
Expand All @@ -117,13 +117,13 @@ const elysia = new Elysia()
return { error: errorMessage.trim() };
} else if(code == 'NOT_FOUND') {
set.status = 404;
return { error: i18n('error.notFound') };
return { error: i18n('$.error.notFound') };
} else {
set.status = 500;
captureException(error);
const requestId = generateSecureCode(32);
Logger.error(`An error ocurred with request ${requestId}: ${error}`);
return { error: i18n('error.unknownError'), id: requestId };
return { error: i18n('$.error.unknownError'), id: requestId };
}
})
.listen({ port: config.port, idleTimeout: 20 });
Expand Down
2 changes: 1 addition & 1 deletion src/libs/Ratelimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default class Ratelimiter {
set.headers['X-RateLimit-Limit'] = String(this.maxRequests);
set.headers['X-RateLimit-Remaining'] = String(ratelimitData.remaining);
set.headers['X-RateLimit-Reset'] = String(ratelimitData.reset / 1000);
if(ratelimitData.limited) return error(429, { error: i18n('error.ratelimit').replaceAll('<seconds>', String(Math.ceil(ratelimitData.reset / 1000))) });
if(ratelimitData.limited) return error(429, { error: i18n('$.error.ratelimit').replaceAll('<seconds>', String(Math.ceil(ratelimitData.reset / 1000))) });
}

public getRatelimitData(ip: string): RatelimitData {
Expand Down
8 changes: 8 additions & 0 deletions src/libs/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readdirSync } from "fs";
import { join } from "path";
import Logger from "./Logger";
import players from "../database/schemas/players";
import { captureException } from "@sentry/bun";

export type Language = Map<string, string>;
export type I18nFunction = (path: string) => string;
Expand Down Expand Up @@ -44,6 +45,13 @@ export function isValidLanguage(language: string): boolean {
}

export function translate(path: string, language: Language): string {
if(!path.startsWith('\$\.')) {
const error = new Error(`Translation path "${path}" was not prefixed with "$."!`);
Logger.warn(error.message);
captureException(error);
} else {
path = path.slice(2);
}
if(language.has(path)) return language.get(path)!;
return getLanguage().get(path) || path;
}
Expand Down
109 changes: 57 additions & 52 deletions src/libs/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ const transporter = createTransport({
}
} as TransportOptions);

export let enabled = mailer.enabled;

export async function verify() {
transporter.verify((error) => {
if(error) Logger.error(`Invalid mailer options: ${error.message}`);
if(error) {
enabled = false;
Logger.error(`Invalid mailer options: ${error.message}`);
}
else Logger.info('Mailer options verified!');
});
}
Expand All @@ -55,127 +60,127 @@ export function sendBanEmail({ address, reason, duration, appealable, i18n }: {
const durationOptions: MailOptions['variables'] = [];

if(!permanent) {
durationOptions.push(['duration', i18n('email.banned.duration')]);
durationOptions.push(['duration_value', i18n('email.banned.until').replace('<date>', moment(duration).format('DD.MM.YYYY HH:mm'))]);
durationOptions.push(['duration', i18n('$.email.banned.duration')]);
durationOptions.push(['duration_value', i18n('$.email.banned.until').replace('<date>', moment(duration).format('DD.MM.YYYY HH:mm'))]);
}

sendEmail({
recipient: address,
subject: i18n('email.banned.subject'),
subject: i18n('$.email.banned.subject'),
template: 'banned',
variables: [
['title', i18n('email.banned.title')],
['greeting', i18n('email.greeting')],
['description', i18n(`email.banned.description.${permanent ? 'permanent' : 'temporary'}`)],
['reason', i18n('email.banned.reason')],
['title', i18n('$.email.banned.title')],
['greeting', i18n('$.email.greeting')],
['description', i18n(permanent ? '$.email.banned.description.permanent' : '$.email.banned.description.temporary')],
['reason', i18n('$.email.banned.reason')],
['reason_value', reason],
['duration_visibility', permanent ? 'none' : 'initial'],
...durationOptions,
['appeal', i18n(`email.banned.${appealable ? 'a' : 'noA'}ppeal`)],
['footer', i18n('email.footer')],
['appeal', i18n(appealable ? '$.email.banned.appeal' : '$.email.banned.noAppeal')],
['footer', i18n('$.email.footer')],
]
});
}

export function sendUnbanEmail(address: string, i18n: I18nFunction) {
sendEmail({
recipient: address,
subject: i18n('email.unbanned.subject'),
subject: i18n('$.email.unbanned.subject'),
template: 'unbanned',
variables: [
['title', i18n('email.unbanned.title')],
['greeting', i18n('email.greeting')],
['unbanned', i18n('email.unbanned.unbanned')],
['access', i18n('email.unbanned.access')],
['footer', i18n('email.footer')],
['title', i18n('$.email.unbanned.title')],
['greeting', i18n('$.email.greeting')],
['unbanned', i18n('$.email.unbanned.unbanned')],
['access', i18n('$.email.unbanned.access')],
['footer', i18n('$.email.footer')],
]
});
}

export function sendTagClearEmail(address: string, tag: string, i18n: I18nFunction) {
sendEmail({
recipient: address,
subject: i18n('email.tagCleared.subject'),
subject: i18n('$.email.tagCleared.subject'),
template: 'tag_cleared',
variables: [
['title', i18n('email.tagCleared.title')],
['greeting', i18n('email.greeting')],
['description', i18n('email.tagCleared.description')],
['title', i18n('$.email.tagCleared.title')],
['greeting', i18n('$.email.greeting')],
['description', i18n('$.email.tagCleared.description')],
['tag', `"${stripColors(tag)}"`],
['warning', i18n('email.tagCleared.warning')],
['footer', i18n('email.footer')],
['warning', i18n('$.email.tagCleared.warning')],
['footer', i18n('$.email.footer')],
]
});
}

export function sendTagChangeEmail(address: string, oldTag: string, newTag: string, i18n: I18nFunction) {
sendEmail({
recipient: address,
subject: i18n('email.tagChanged.subject'),
subject: i18n('$.email.tagChanged.subject'),
template: 'tag_changed',
variables: [
['title', i18n('email.tagChanged.title')],
['greeting', i18n('email.greeting')],
['description', i18n('email.tagChanged.description')],
['previous', i18n('email.tagChanged.previous')],
['title', i18n('$.email.tagChanged.title')],
['greeting', i18n('$.email.greeting')],
['description', i18n('$.email.tagChanged.description')],
['previous', i18n('$.email.tagChanged.previous')],
['old_tag', `"${stripColors(oldTag)}"`],
['new', i18n('email.tagChanged.new')],
['new', i18n('$.email.tagChanged.new')],
['new_tag', `"${stripColors(newTag)}"`],
['warning', i18n('email.tagChanged.warning')],
['footer', i18n('email.footer')],
['warning', i18n('$.email.tagChanged.warning')],
['footer', i18n('$.email.footer')],
]
});
}

export function sendPositionChangeEmail(address: string, oldPosition: string, newPosition: string, i18n: I18nFunction) {
sendEmail({
recipient: address,
subject: i18n('email.positionChanged.subject'),
subject: i18n('$.email.positionChanged.subject'),
template: 'position_changed',
variables: [
['title', i18n('email.positionChanged.title')],
['greeting', i18n('email.greeting')],
['description', i18n('email.positionChanged.description')],
['previous', i18n('email.positionChanged.previous')],
['title', i18n('$.email.positionChanged.title')],
['greeting', i18n('$.email.greeting')],
['description', i18n('$.email.positionChanged.description')],
['previous', i18n('$.email.positionChanged.previous')],
['old_position', capitalCase(oldPosition)],
['new', i18n('email.positionChanged.new')],
['new', i18n('$.email.positionChanged.new')],
['new_position', capitalCase(newPosition)],
['warning', i18n('email.positionChanged.warning')],
['footer', i18n('email.footer')],
['warning', i18n('$.email.positionChanged.warning')],
['footer', i18n('$.email.footer')],
]
});
}

export function sendIconTypeChangeEmail(address: string, oldIcon: string, newIcon: string, i18n: I18nFunction) {
sendEmail({
recipient: address,
subject: i18n('email.iconChanged.subject'),
subject: i18n('$.email.iconChanged.subject'),
template: 'icon_changed',
variables: [
['title', i18n('email.iconChanged.title')],
['greeting', i18n('email.greeting')],
['description', i18n('email.iconChanged.description')],
['previous', i18n('email.iconChanged.previous')],
['title', i18n('$.email.iconChanged.title')],
['greeting', i18n('$.email.greeting')],
['description', i18n('$.email.iconChanged.description')],
['previous', i18n('$.email.iconChanged.previous')],
['old_icon', capitalCase(oldIcon)],
['new', i18n('email.iconChanged.new')],
['new', i18n('$.email.iconChanged.new')],
['new_icon', capitalCase(newIcon)],
['warning', i18n('email.iconChanged.warning')],
['footer', i18n('email.footer')],
['warning', i18n('$.email.iconChanged.warning')],
['footer', i18n('$.email.footer')],
]
});
}

export function sendIconClearEmail(address: string, i18n: I18nFunction) {
sendEmail({
recipient: address,
subject: i18n('email.iconCleared.subject'),
subject: i18n('$.email.iconCleared.subject'),
template: 'icon_cleared',
variables: [
['title', i18n('email.iconCleared.title')],
['greeting', i18n('email.greeting')],
['description', i18n('email.iconCleared.description')],
['warning', i18n('email.iconCleared.warning')],
['footer', i18n('email.footer')],
['title', i18n('$.email.iconCleared.title')],
['greeting', i18n('$.email.greeting')],
['description', i18n('$.email.iconCleared.description')],
['warning', i18n('$.email.iconCleared.warning')],
['footer', i18n('$.email.footer')],
]
});
}
2 changes: 1 addition & 1 deletion src/middleware/database-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { getI18nFunctionByLanguage } from "./fetch-i18n";

export default function checkDatabase({ error, request: { headers } }: PreContext) {
const i18n = getI18nFunctionByLanguage(headers.get('x-language') || undefined);
if(!isConnected()) return error(503, { error: i18n('error.database') });
if(!isConnected()) return error(503, { error: i18n('$.error.database') });
}
Loading