fix(mukke): lint fixes, rate limiting, DB indexes, and prod config

- Add ThrottlerModule rate limiting to backend
- Add DB indexes on userId, songId, playlistId columns
- Lint/formatter fixes from pre-commit hooks
- Add GlitchTip error tracking to docker-compose.macmini.yml
- Add glitchtip.mana.how to cloudflared tunnel config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 12:54:49 +01:00
parent 212ec16125
commit 54c1326c14
30 changed files with 326 additions and 141 deletions

View file

@ -31,6 +31,7 @@
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { DatabaseModule } from './db/database.module';
import { ProjectModule } from './project/project.module';
import { BeatModule } from './beat/beat.module';
@ -18,6 +19,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
isGlobal: true,
envFilePath: '.env',
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
DatabaseModule,
ProjectModule,
BeatModule,

View file

@ -1,4 +1,14 @@
import { IsString, IsNotEmpty, IsUUID, IsNumber, IsOptional, IsObject } from 'class-validator';
import {
IsString,
IsNotEmpty,
IsUUID,
IsNumber,
IsOptional,
IsObject,
MaxLength,
Min,
Max,
} from 'class-validator';
export class CreateBeatUploadDto {
@IsUUID()
@ -7,6 +17,7 @@ export class CreateBeatUploadDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
filename!: string;
}
@ -19,14 +30,19 @@ export class UseLibraryBeatDto {
export class UpdateBeatMetadataDto {
@IsNumber()
@IsOptional()
@Min(0)
duration?: number;
@IsNumber()
@IsOptional()
@Min(1)
@Max(999)
bpm?: number;
@IsNumber()
@IsOptional()
@Min(0)
@Max(1)
bpmConfidence?: number;
@IsObject()

View file

@ -1,7 +1,9 @@
import { pgTable, uuid, text, timestamp, varchar, real, jsonb } from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, varchar, real, jsonb, index } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
export const beats = pgTable('beats', {
export const beats = pgTable(
'beats',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
@ -17,7 +19,9 @@ export const beats = pgTable('beats', {
transcriptionError: text('transcription_error'),
transcribedAt: timestamp('transcribed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => [index('beats_project_id_idx').on(table.projectId)]
);
export type Beat = typeof beats.$inferSelect;
export type NewBeat = typeof beats.$inferInsert;

View file

@ -1,16 +1,22 @@
import { pgTable, uuid, text, real, integer } from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, real, integer, index } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
export const lyrics = pgTable('lyrics', {
export const lyrics = pgTable(
'lyrics',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull()
.unique(),
content: text('content'),
});
},
(table) => [index('lyrics_project_id_idx').on(table.projectId)]
);
export const lyricLines = pgTable('lyric_lines', {
export const lyricLines = pgTable(
'lyric_lines',
{
id: uuid('id').primaryKey().defaultRandom(),
lyricsId: uuid('lyrics_id')
.references(() => lyrics.id, { onDelete: 'cascade' })
@ -19,7 +25,9 @@ export const lyricLines = pgTable('lyric_lines', {
text: text('text').notNull(),
startTime: real('start_time'),
endTime: real('end_time'),
});
},
(table) => [index('lyric_lines_lyrics_id_idx').on(table.lyricsId)]
);
export type Lyrics = typeof lyrics.$inferSelect;
export type NewLyrics = typeof lyrics.$inferInsert;

View file

@ -1,7 +1,9 @@
import { pgTable, uuid, varchar, real, integer } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, real, integer, index } from 'drizzle-orm/pg-core';
import { beats } from './beats.schema';
export const markers = pgTable('markers', {
export const markers = pgTable(
'markers',
{
id: uuid('id').primaryKey().defaultRandom(),
beatId: uuid('beat_id')
.references(() => beats.id, { onDelete: 'cascade' })
@ -12,7 +14,9 @@ export const markers = pgTable('markers', {
endTime: real('end_time'),
color: varchar('color', { length: 7 }),
sortOrder: integer('sort_order'),
});
},
(table) => [index('markers_beat_id_idx').on(table.beatId)]
);
export type Marker = typeof markers.$inferSelect;
export type NewMarker = typeof markers.$inferInsert;

View file

@ -1,7 +1,9 @@
import { pgTable, uuid, text, timestamp, varchar, integer } from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core';
import { songs } from './songs.schema';
export const playlists = pgTable('playlists', {
export const playlists = pgTable(
'playlists',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
@ -9,9 +11,13 @@ export const playlists = pgTable('playlists', {
coverArtPath: text('cover_art_path'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => [index('playlists_user_id_idx').on(table.userId)]
);
export const playlistSongs = pgTable('playlist_songs', {
export const playlistSongs = pgTable(
'playlist_songs',
{
id: uuid('id').primaryKey().defaultRandom(),
playlistId: uuid('playlist_id')
.references(() => playlists.id, { onDelete: 'cascade' })
@ -21,7 +27,12 @@ export const playlistSongs = pgTable('playlist_songs', {
.notNull(),
sortOrder: integer('sort_order').notNull(),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => [
index('playlist_songs_playlist_id_idx').on(table.playlistId),
index('playlist_songs_song_id_idx').on(table.songId),
]
);
export type Playlist = typeof playlists.$inferSelect;
export type NewPlaylist = typeof playlists.$inferInsert;

View file

@ -1,7 +1,9 @@
import { pgTable, uuid, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, varchar, index } from 'drizzle-orm/pg-core';
import { songs } from './songs.schema';
export const projects = pgTable('projects', {
export const projects = pgTable(
'projects',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
@ -9,7 +11,12 @@ export const projects = pgTable('projects', {
songId: uuid('song_id').references(() => songs.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => [
index('projects_user_id_idx').on(table.userId),
index('projects_song_id_idx').on(table.songId),
]
);
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;

View file

@ -7,9 +7,12 @@ import {
integer,
real,
boolean,
index,
} from 'drizzle-orm/pg-core';
export const songs = pgTable('songs', {
export const songs = pgTable(
'songs',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
@ -29,7 +32,12 @@ export const songs = pgTable('songs', {
lastPlayedAt: timestamp('last_played_at', { withTimezone: true }),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => [
index('songs_user_id_idx').on(table.userId),
index('songs_added_at_idx').on(table.addedAt),
]
);
export type Song = typeof songs.$inferSelect;
export type NewSong = typeof songs.$inferInsert;

View file

@ -6,11 +6,14 @@ import {
ValidateNested,
IsInt,
Min,
MaxLength,
ArrayMaxSize,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateOrUpdateLyricsDto {
@IsString()
@MaxLength(50000)
content!: string;
}
@ -20,6 +23,7 @@ class LyricLineDto {
lineNumber!: number;
@IsString()
@MaxLength(1000)
text!: string;
@IsNumber()
@ -36,6 +40,7 @@ class LyricLineDto {
export class SyncLinesDto {
@IsArray()
@ValidateNested({ each: true })
@ArrayMaxSize(2000)
@Type(() => LyricLineDto)
lines!: LyricLineDto[];
}

View file

@ -9,6 +9,7 @@ import {
ValidateNested,
MaxLength,
Min,
ArrayMaxSize,
} from 'class-validator';
import { Type } from 'class-transformer';
@ -115,6 +116,7 @@ export class BulkCreateMarkersDto {
@IsArray()
@ValidateNested({ each: true })
@ArrayMaxSize(500)
@Type(() => MarkerItemDto)
markers!: MarkerItemDto[];
}
@ -132,6 +134,7 @@ class MarkerUpdateItemDto {
export class BulkUpdateMarkersDto {
@IsArray()
@ValidateNested({ each: true })
@ArrayMaxSize(500)
@Type(() => MarkerUpdateItemDto)
updates!: MarkerUpdateItemDto[];
}

View file

@ -100,11 +100,20 @@ export class MarkerService {
data: Partial<Pick<Marker, 'startTime' | 'endTime' | 'sortOrder'>>;
}>
): Promise<Marker[]> {
return this.db.transaction(async (tx) => {
const results: Marker[] = [];
for (const update of updates) {
const marker = await this.update(update.id, userId, update.data);
results.push(marker);
const marker = await this.findByIdOrThrow(update.id);
await this.verifyBeatOwnership(marker.beatId, userId);
const [updatedMarker] = await tx
.update(markers)
.set(update.data)
.where(eq(markers.id, update.id))
.returning();
results.push(updatedMarker);
}
return results;
});
}
}

View file

@ -1,4 +1,12 @@
import { IsString, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator';
import {
IsString,
IsNotEmpty,
IsOptional,
IsUUID,
MaxLength,
ArrayMaxSize,
IsArray,
} from 'class-validator';
export class CreatePlaylistDto {
@IsString()
@ -8,6 +16,7 @@ export class CreatePlaylistDto {
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
}
@ -19,6 +28,7 @@ export class UpdatePlaylistDto {
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
}
@ -29,7 +39,9 @@ export class AddSongDto {
}
export class ReorderSongsDto {
@IsArray()
@IsUUID('4', { each: true })
@IsNotEmpty({ each: true })
@ArrayMaxSize(1000)
songIds!: string[];
}

View file

@ -121,11 +121,15 @@ export class PlaylistService {
async reorderSongs(playlistId: string, userId: string, songIds: string[]): Promise<void> {
await this.findById(playlistId, userId);
await this.db.transaction(async (tx) => {
for (let i = 0; i < songIds.length; i++) {
await this.db
await tx
.update(playlistSongs)
.set({ sortOrder: i })
.where(and(eq(playlistSongs.playlistId, playlistId), eq(playlistSongs.songId, songIds[i])));
}
.where(
and(eq(playlistSongs.playlistId, playlistId), eq(playlistSongs.songId, songIds[i]))
);
}
});
}
}

View file

@ -8,6 +8,7 @@ export class CreateProjectDto {
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
}
@ -19,5 +20,6 @@ export class UpdateProjectDto {
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
}

View file

@ -67,17 +67,22 @@ export class ProjectService {
}
const title = song.artist ? `${song.title} - ${song.artist}` : song.title;
const [project] = await this.db.insert(projects).values({ userId, title, songId }).returning();
const project = await this.db.transaction(async (tx) => {
const [newProject] = await tx.insert(projects).values({ userId, title, songId }).returning();
// Create a beat record linked to the song's storage
await this.db.insert(beats).values({
projectId: project.id,
await tx.insert(beats).values({
projectId: newProject.id,
storagePath: song.storagePath,
filename: `${song.title}.mp3`,
duration: song.duration,
bpm: song.bpm,
});
return newProject;
});
return project;
}

View file

@ -1,24 +1,38 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsInt } from 'class-validator';
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsInt,
MaxLength,
Min,
Max,
} from 'class-validator';
export class CreateSongDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
title!: string;
@IsString()
@IsOptional()
@MaxLength(255)
artist?: string;
@IsString()
@IsOptional()
@MaxLength(255)
album?: string;
@IsString()
@IsOptional()
@MaxLength(100)
genre?: string;
@IsInt()
@IsOptional()
@Min(1)
trackNumber?: number;
@IsInt()
@ -27,32 +41,40 @@ export class CreateSongDto {
@IsNumber()
@IsOptional()
@Min(1)
@Max(999)
bpm?: number;
}
export class UpdateSongDto {
@IsString()
@IsOptional()
@MaxLength(255)
title?: string;
@IsString()
@IsOptional()
@MaxLength(255)
artist?: string;
@IsString()
@IsOptional()
@MaxLength(255)
album?: string;
@IsString()
@IsOptional()
@MaxLength(255)
albumArtist?: string;
@IsString()
@IsOptional()
@MaxLength(100)
genre?: string;
@IsInt()
@IsOptional()
@Min(1)
trackNumber?: number;
@IsInt()
@ -61,19 +83,24 @@ export class UpdateSongDto {
@IsNumber()
@IsOptional()
@Min(0)
duration?: number;
@IsNumber()
@IsOptional()
@Min(1)
@Max(999)
bpm?: number;
@IsInt()
@IsOptional()
@Min(0)
fileSize?: number;
}
export class SongUploadDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
filename!: string;
}

View file

@ -57,11 +57,12 @@
error = null;
try {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${backendUrl}/beats/library/${beatId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
},
body: JSON.stringify({ projectId }),
});

View file

@ -238,13 +238,11 @@ export const authStore = {
return await tokenManager.getValidToken();
},
getAuthHeaders(): Record<string, string> {
const authService = getAuthService();
if (!authService) return {};
async getAuthHeaders(): Promise<Record<string, string>> {
const tokenManager = getTokenManager();
if (!tokenManager) return {};
// Get token synchronously from storage if available
const token =
typeof localStorage !== 'undefined' ? localStorage.getItem('manacore_access_token') : null;
const token = await tokenManager.getValidToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}

View file

@ -48,11 +48,12 @@ function createLibraryStore() {
});
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
...options.headers,
},
});

View file

@ -73,11 +73,12 @@ function createPlayerStore() {
}
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
...options.headers,
},
});

View file

@ -28,11 +28,12 @@ function createPlaylistStore() {
});
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
...options.headers,
},
});

View file

@ -36,11 +36,12 @@ function createProjectStore() {
});
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
...options.headers,
},
});

View file

@ -215,10 +215,11 @@
(typeof window !== 'undefined' &&
(window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__) ||
'http://localhost:3010';
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(
`${backendUrl}/export/${projectStore.currentProject.id}?format=${format}`,
{
headers: authStore.getAuthHeaders(),
headers: authHeaders,
}
);

View file

@ -17,11 +17,12 @@
}
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
...options.headers,
},
});

View file

@ -15,10 +15,11 @@
}
async function fetchApi<T>(path: string): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
},
});
if (!response.ok) throw new Error('Request failed');

View file

@ -15,10 +15,11 @@
}
async function fetchApi<T>(path: string): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
},
});
if (!response.ok) throw new Error('Request failed');

View file

@ -15,10 +15,11 @@
}
async function fetchApi<T>(path: string): Promise<T> {
const authHeaders = await authStore.getAuthHeaders();
const response = await fetch(`${getBackendUrl()}${path}`, {
headers: {
'Content-Type': 'application/json',
...authStore.getAuthHeaders(),
...authHeaders,
},
});
if (!response.ok) throw new Error('Request failed');

View file

@ -100,5 +100,9 @@ ingress:
- hostname: link.mana.how
service: http://localhost:4090
# GlitchTip Error Tracking
- hostname: glitchtip.mana.how
service: http://localhost:8020
# Catch-all
- service: http_status:404

View file

@ -1905,6 +1905,51 @@ services:
# Volumes (Naming: mana-{service}-data)
# ============================================
# ============================================
# GlitchTip Error Tracking (Sentry-compatible)
# ============================================
glitchtip:
image: glitchtip/glitchtip:latest
container_name: mana-mon-glitchtip
restart: always
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/glitchtip
SECRET_KEY: ${GLITCHTIP_SECRET_KEY:-change-me-in-production}
PORT: "8020"
GLITCHTIP_DOMAIN: https://glitchtip.mana.how
DEFAULT_FROM_EMAIL: glitchtip@mana.how
CELERY_WORKER_AUTOSCALE: "1,3"
ENABLE_USER_REGISTRATION: "false"
ports:
- "8020:8020"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8020/_health/"]
interval: 30s
timeout: 10s
retries: 3
glitchtip-worker:
image: glitchtip/glitchtip:latest
container_name: mana-mon-glitchtip-worker
restart: always
command: ./bin/run-celery-with-beat.sh
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/glitchtip
SECRET_KEY: ${GLITCHTIP_SECRET_KEY:-change-me-in-production}
GLITCHTIP_DOMAIN: https://glitchtip.mana.how
CELERY_WORKER_AUTOSCALE: "1,3"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
redis_data:
name: mana-redis-data