Integrate Oudelaa backend features, security, tests, and deployment updates
This commit is contained in:
@@ -9,10 +9,14 @@ export const decodeOffsetCursor = (cursor?: string): number | null => {
|
||||
try {
|
||||
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return null;
|
||||
if (Number.isInteger(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
return parsed;
|
||||
|
||||
const payload = JSON.parse(raw) as { offset?: unknown };
|
||||
return Number.isInteger(payload.offset) && Number(payload.offset) >= 0
|
||||
? Number(payload.offset)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { compareHash, compareStoredHighEntropyValue, hashHighEntropyValue, hashValue } from './hash.util';
|
||||
|
||||
describe('hash utilities', () => {
|
||||
it('hashes and verifies passwords with bcrypt', async () => {
|
||||
const hash = await hashValue('secret', 4);
|
||||
await expect(compareHash('secret', hash)).resolves.toBe(true);
|
||||
await expect(compareHash('wrong', hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('uses deterministic secret-keyed hashes for high entropy tokens', async () => {
|
||||
const stored = hashHighEntropyValue('token', 'pepper');
|
||||
expect(stored).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
await expect(compareStoredHighEntropyValue('token', stored, 'pepper')).resolves.toBe(true);
|
||||
await expect(compareStoredHighEntropyValue('other', stored, 'pepper')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('supports legacy bcrypt token hashes', async () => {
|
||||
const stored = await bcrypt.hash('legacy-token', 4);
|
||||
await expect(compareStoredHighEntropyValue('legacy-token', stored, 'unused')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { buildAiMusicShareUrl, buildPostShareUrl, buildProfileShareUrl, normalizeShareBaseUrl, resolveShareBaseUrl } from './share-url.util';
|
||||
|
||||
describe('share URL utilities', () => {
|
||||
const originalShare = process.env.SHARE_BASE_URL;
|
||||
const originalPublicWeb = process.env.PUBLIC_WEB_URL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShare === undefined) delete process.env.SHARE_BASE_URL;
|
||||
else process.env.SHARE_BASE_URL = originalShare;
|
||||
if (originalPublicWeb === undefined) delete process.env.PUBLIC_WEB_URL;
|
||||
else process.env.PUBLIC_WEB_URL = originalPublicWeb;
|
||||
});
|
||||
|
||||
it('normalizes defaults and trailing slashes', () => {
|
||||
expect(normalizeShareBaseUrl(undefined)).toBe('https://oudelaa.com');
|
||||
expect(normalizeShareBaseUrl(' https://web.test/// ')).toBe('https://web.test');
|
||||
});
|
||||
|
||||
it('resolves environment before configuration', () => {
|
||||
process.env.SHARE_BASE_URL = 'https://env.test/';
|
||||
const config = { get: jest.fn(() => 'https://config.test') } as unknown as ConfigService;
|
||||
expect(resolveShareBaseUrl(config)).toBe('https://env.test');
|
||||
expect(config.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds encoded profile, post, and AI music links', () => {
|
||||
expect(buildProfileShareUrl('https://web', { username: 'a/b' })).toBe('https://web/u/a%2Fb');
|
||||
expect(buildProfileShareUrl('https://web', { _id: { toString: () => 'id one' } })).toBe('https://web/profile/id%20one');
|
||||
expect(buildPostShareUrl('https://web', 'post/1')).toBe('https://web/posts/post%2F1');
|
||||
expect(buildAiMusicShareUrl('https://web', 'track 1')).toBe('https://web/ai/music/track%201');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { verifyTotp } from './totp.util';
|
||||
|
||||
describe('verifyTotp', () => {
|
||||
it('accepts a known code and rejects invalid values', () => {
|
||||
expect(verifyTotp('JBSWY3DPEHPK3PXP', '282760', 0)).toBe(true);
|
||||
expect(verifyTotp('JBSWY3DPEHPK3PXP', '000000', 0)).toBe(false);
|
||||
expect(verifyTotp('not-base32!', '282760', 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const decodeBase32 = (value: string): Buffer => {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const normalized = value.toUpperCase().replace(/=|\s|-/g, '');
|
||||
let bits = '';
|
||||
for (const character of normalized) {
|
||||
const index = alphabet.indexOf(character);
|
||||
if (index < 0) return Buffer.alloc(0);
|
||||
bits += index.toString(2).padStart(5, '0');
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let offset = 0; offset + 8 <= bits.length; offset += 8) {
|
||||
bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2));
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
};
|
||||
|
||||
const generateTotp = (secret: Buffer, counter: number): string => {
|
||||
const message = Buffer.alloc(8);
|
||||
message.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = createHmac('sha1', secret).update(message).digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
return ((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000)
|
||||
.toString()
|
||||
.padStart(6, '0');
|
||||
};
|
||||
|
||||
export const verifyTotp = (base32Secret: string, suppliedCode: string, nowMs = Date.now()): boolean => {
|
||||
if (!/^\d{6}$/.test(suppliedCode)) return false;
|
||||
const secret = decodeBase32(base32Secret);
|
||||
if (!secret.length) return false;
|
||||
const counter = Math.floor(nowMs / 30_000);
|
||||
return [-1, 0, 1].filter((drift) => counter + drift >= 0).some((drift) => {
|
||||
const expected = Buffer.from(generateTotp(secret, counter + drift));
|
||||
const supplied = Buffer.from(suppliedCode);
|
||||
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user