diff --git a/db/migration/019-add-tunicwilds.sql b/db/migration/019-add-tunicwilds.sql new file mode 100644 index 0000000..0779d9c --- /dev/null +++ b/db/migration/019-add-tunicwilds.sql @@ -0,0 +1,10 @@ +CREATE TABLE "Tunicwild" ( + "id" integer PRIMARY KEY AUTOINCREMENT, + "memberDiscord" text NOT NULL, + "composer" text NOT NULL, + "title" text NOT NULL, + "game" text NOT NULL, + "releaseDate" text NOT NULL, + "officialLink" text NOT NULL, + FOREIGN KEY ("memberDiscord") REFERENCES "Member" ("discord") +); diff --git a/development.sh b/development.sh index 92ebeb5..cecd31b 100755 --- a/development.sh +++ b/development.sh @@ -13,22 +13,40 @@ export CORPORATE_URL= export DATABASE_URL="file:./$database" export DIGITALOCEAN_DNS_TOKEN= export LATEX_WEB_SYSTEMD_UNIT= +export PUBLIC_DIRECTORY='public' export SECRET_HMAC_KEY='dev' export SIGHTS_RUN_AFTER_UPLOAD= export SIGHTS_UPLOAD_DIRECTORY='dev/sights' export SOUNDS_RUN_AFTER_UPLOAD= export SOUNDS_UPLOAD_DIRECTORY='dev/sounds' +export TUNICWILDS_DAILY_FILE='dev/tunicwilds-daily.json' +export TUNICWILDS_RENDERED_DIRECTORY='dev/tunicwilds-rendered' +export TUNICWILDS_RUN_AFTER_RENDER= +export TUNICWILDS_UPLOAD_DIRECTORY='dev/tunicwilds' +export TUNICWILDS_UPLOAD_URL= export WORDS_RUN_AFTER_UPLOAD= export WORDS_UPLOAD_DIRECTORY='dev/words' -export PUBLIC_DIRECTORY='public' + +if test $# -gt 0; then + exec "$@" +fi # Clean up files from last run -rm -rf "$database" "$SIGHTS_UPLOAD_DIRECTORY" "$SOUNDS_UPLOAD_DIRECTORY" "$WORDS_UPLOAD_DIRECTORY" +rm -rf \ + "$database" \ + "$SIGHTS_UPLOAD_DIRECTORY" \ + "$SOUNDS_UPLOAD_DIRECTORY" \ + "$WORDS_UPLOAD_DIRECTORY" \ + "$TUNICWILDS_DAILY_FILE" \ + "$TUNICWILDS_RENDERED_DIRECTORY" \ + "$TUNICWILDS_UPLOAD_DIRECTORY" # Create directories mkdir -p "$SIGHTS_UPLOAD_DIRECTORY" mkdir -p "$SOUNDS_UPLOAD_DIRECTORY" mkdir -p "$WORDS_UPLOAD_DIRECTORY" +mkdir -p "$TUNICWILDS_RENDERED_DIRECTORY" +mkdir -p "$TUNICWILDS_UPLOAD_DIRECTORY" # Create database node db/migrate.mjs "$database" diff --git a/src/bin/start-tunicwild-day.ts b/src/bin/start-tunicwild-day.ts new file mode 100644 index 0000000..b768a17 --- /dev/null +++ b/src/bin/start-tunicwild-day.ts @@ -0,0 +1,186 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile, unlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { notInArray, sql } from "drizzle-orm"; +import db from "../database/db"; +import { Tunicwild } from "../database/schema"; + +if (!process.env.TUNICWILDS_DAILY_FILE) { + console.error("TUNICWILDS_DAILY_FILE not set"); + process.exit(1); +} + +if (!process.env.TUNICWILDS_RENDERED_DIRECTORY) { + console.error("TUNICWILDS_RENDERED_DIRECTORY not set"); + process.exit(1); +} + +if (!process.env.TUNICWILDS_UPLOAD_URL && !process.env.TUNICWILDS_UPLOAD_DIRECTORY) { + console.error("TUNICWILDS_UPLOAD_* not set"); + process.exit(1); +} + +interface DailyInfo { + active: { + audioFilenames: string[]; + date: string; + id: number; + }[]; + recentIds: number[]; + startDate: string; +} + +const audioLengths = [0.5, 1, 2, 4, 8, 16] as const; + +async function renderAudio(id: number): Promise { + let audioBuffer: NodeJS.ArrayBufferView; + if (process.env.TUNICWILDS_UPLOAD_URL) { + const response = await fetch(`${process.env.TUNICWILDS_UPLOAD_URL}/${id}`); + audioBuffer = await response.bytes(); + } else if (process.env.TUNICWILDS_UPLOAD_DIRECTORY) { + audioBuffer = await readFile(join(process.env.TUNICWILDS_UPLOAD_DIRECTORY, id.toString())); + } else { + throw new Error(); + } + + const ffmpegStatsResult = spawnSync("ffmpeg", [ + "-v", "quiet", + "-stats", + "-i", "pipe:", + "-map", "0:a:0", + "-f", "null", + "-", + ], { + encoding: "utf8", + input: audioBuffer, + stdio: ["pipe", "ignore", "pipe"], + }); + + if (ffmpegStatsResult.error != null) { + throw ffmpegStatsResult.error; + } + + const timeMatch = ffmpegStatsResult.stderr.match(/time=(\d{2,}):(\d{2}):(\d{2}\.\d{2})[^\r]*$/); + + if (timeMatch == null) { + throw new Error(`Unexpected ffmpeg output:\n${ffmpegStatsResult}`); + } + + const durationSeconds = + Number.parseInt(timeMatch[1]!, 10) * 60 * 60 + + Number.parseInt(timeMatch[2]!, 10) * 60 + + Number.parseFloat(timeMatch[3]!); + + const filenames = audioLengths.map(() => `${randomBytes(16).toString("hex")}.mp3`); + const maxAudioLength = audioLengths[audioLengths.length - 1]!; + const maxAudioLengthFilename = filenames[audioLengths.length - 1]!; + + execFileSync("ffmpeg", [ + "-v", "quiet", + "-ss", Math.max(0, Math.floor((durationSeconds - maxAudioLength) * Math.random())).toString(), + "-t", maxAudioLength.toString(), + "-i", "pipe:", + "-map", "0:a:0", + "-map_metadata", "-1", + "-c:a", "libmp3lame", + "-q:a", "0", + join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, maxAudioLengthFilename), + ], { + input: audioBuffer, + stdio: ["pipe", "ignore", "ignore"], + }); + + for (let i = 0; i < audioLengths.length - 1; i++) { + execFileSync("ffmpeg", [ + "-v", "quiet", + "-t", audioLengths[i]!.toString(), + "-i", join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, maxAudioLengthFilename), + "-c", "copy", + join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, filenames[i]!), + ], { + stdio: "ignore", + }); + } + + if (process.env.TUNICWILDS_RUN_AFTER_RENDER) { + execFileSync( + process.env.TUNICWILDS_RUN_AFTER_RENDER, + filenames.map( + (filename) => join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, filename), + ), + { stdio: "ignore" }, + ); + } + + return filenames; +} + +// From "dist/server/bin/" +process.chdir(resolve(import.meta.dirname, "../../..")); + +let dailyInfo: DailyInfo; +try { + dailyInfo = JSON.parse(await readFile(process.env.TUNICWILDS_DAILY_FILE, "utf8")); +} catch { + dailyInfo = { + active: [], + recentIds: [], + startDate: new Date().toISOString().slice(0, 10), + }; +} + +if (dailyInfo.active.length === 0) { + const tunicwilds = await db + .select() + .from(Tunicwild) + .orderBy(sql`RANDOM()`) + .limit(3); + + if (tunicwilds.length !== 3) { + console.error("Not enough Tunicwilds in database"); + process.exit(1); + } + + for (let i = 0; i < tunicwilds.length; i++) { + dailyInfo.active.push({ + audioFilenames: await renderAudio(tunicwilds[i]!.id), + date: new Date(Date.now() + (i - 1) * 1000 * 60 * 60 * 24).toISOString().slice(0, 10), + id: tunicwilds[i]!.id, + }); + dailyInfo.recentIds.push(tunicwilds[i]!.id); + } +} else { + const tunicwild = await db + .select() + .from(Tunicwild) + .where(notInArray(Tunicwild.id, dailyInfo.recentIds)) + .orderBy(sql`RANDOM()`) + .get(); + + if (tunicwild == null) { + console.error("Not enough Tunicwilds in database"); + process.exit(1); + } + + dailyInfo.active.push({ + audioFilenames: await renderAudio(tunicwild.id), + date: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString().slice(0, 10), + id: tunicwild.id, + }); + dailyInfo.recentIds.push(tunicwild.id); + + if (dailyInfo.active.length > 3) { + const dropped = dailyInfo.active.shift()!; + + await Promise.all(dropped.audioFilenames.map( + (filename) => unlink(join(process.env.TUNICWILDS_RENDERED_DIRECTORY!, filename)), + )); + } + + if (dailyInfo.recentIds.length > 30) { + dailyInfo.recentIds.shift(); + } +} + +await writeFile(process.env.TUNICWILDS_DAILY_FILE, JSON.stringify(dailyInfo)); diff --git a/src/components/Tunicwilds/Tunicwilds.svelte b/src/components/Tunicwilds/Tunicwilds.svelte new file mode 100644 index 0000000..d673fdb --- /dev/null +++ b/src/components/Tunicwilds/Tunicwilds.svelte @@ -0,0 +1,783 @@ + + + + +
+ +
+

TUNICWILDS

+

Guess the song from the game

+

+ {date.toLocaleDateString()} â€ĸ {guesses.length}/{maxGuesses} guesses +

+ +
+ + {#if showSongList} +
+
+

Song List

+ + {#each Object.entries(gameGroupedSongList) as [game, songs]} +
+

{game}

+ +
+ {/each} +
+
+ {/if} + + {#if error} +

Error: {error}

+ {:else if songData == null} +

Loading today's song...

+ {:else} + + {#if result != null} +
+
+ {#if result} +

+ 😃 Nice +

+

+ You guessed it in {guesses.length} + {guesses.length === 1 ? "try" : "tries"}! +

+ {:else} +

+ 😔 Game Over +

+

Better luck next time!

+ {/if} +
+ + + + {songData.tunicwild.composer} - "{songData.tunicwild + .title}" + + {songData.tunicwild.game} + + +
+ +
+
+ {/if} + + +
+

Your Guesses:

+
+ {#each Array(maxGuesses) as _, index} + {@const guess = guesses[index]} + {@const guessedSong = songList.find( + (song) => song.id === guess?.id, + )} + +
+
+ {#if guessedSong != null} + {guessedSong.composer} - "{guessedSong.title}" + ({guessedSong.game}) + {:else} +
+ {guess === undefined + ? "—" + : guess == null + ? "Skipped" + : "Song unavailable"} +
+ {/if} + + {clipLengths[index]}s + +
+
+ {/each} +
+
+ + +
+ {#if result == null} +
+
+ Clip length: {clipLengths[guesses.length]}s +
+
+ Attempt {guesses.length + 1} of {maxGuesses} +
+
+ {/if} + +
+ +
+ + + {#if result == null} + {#if songData.tunicwild.releaseDate != null} +
+ 💡 Hint: This song was released on + {new Date( + songData.tunicwild.releaseDate, + ).toLocaleDateString(undefined, { + dateStyle: "long", + timeZone: "UTC", + })} +
+ {/if} + + {#if songData.tunicwild.game != null && !guesses.some((guess) => guess?.result === "correctGame")} +
+ 💡 Hint: This song is from + {songData.tunicwild.game} +
+ {/if} + {/if} + + +
+ {#each clipLengths as length} +
+ {/each} +
+
+ + +
+ + + {#if result == null} + + {/if} + {/if} +
+ + diff --git a/src/database/factories.ts b/src/database/factories.ts index e70abd6..309d43c 100644 --- a/src/database/factories.ts +++ b/src/database/factories.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import defineFactory from "./defineFactory"; -import { Action, ActionItem, Member, Motion, Session, Sight, Sound, Ticket, Word } from "./schema"; +import { Action, ActionItem, Member, Motion, Session, Sight, Sound, Ticket, Tunicwild, Word } from "./schema"; function unique(fn: () => T): () => T { const used = new Set(); @@ -83,6 +83,15 @@ export const TicketFactory = defineFactory(Ticket, { hash: () => faker.string.alphanumeric(32), }); +export const TunicwildFactory = defineFactory(Tunicwild, { + memberDiscord: () => { throw new Error("Not implemented"); }, + composer: () => faker.person.firstName(), + title: () => faker.music.songName(), + game: () => faker.commerce.productName(), + releaseDate: () => faker.date.past(), + officialLink: () => faker.internet.url(), +}); + export const WordFactory = defineFactory(Word, { title: () => faker.book.title(), memberDiscord: () => { throw new Error("Not implemented"); }, diff --git a/src/database/schema.ts b/src/database/schema.ts index 9f4f58d..6d205bb 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -1,6 +1,5 @@ import { relations, sql } from "drizzle-orm"; import { sqliteTable, integer, text, customType, uniqueIndex } from "drizzle-orm/sqlite-core"; -import type { SessionData } from "../server/session"; const date = customType<{ data: Date; driverData: string }>({ dataType: () => "text", @@ -63,6 +62,7 @@ export const MemberRelations = relations(Member, ({ many }) => ({ sights: many(Sight), sounds: many(Sound), tickets: many(Ticket), + tunicwilds: many(Tunicwild), words: many(Word), })); @@ -146,6 +146,23 @@ export const TicketRelations = relations(Ticket, ({ one }) => ({ }), })); +export const Tunicwild = sqliteTable("Tunicwild", { + id: integer().primaryKey({ autoIncrement: true }), + memberDiscord: text().notNull().references(() => Member.discord), + composer: text().notNull(), + title: text().notNull(), + game: text().notNull(), + releaseDate: date().notNull(), + officialLink: text().notNull(), +}); + +export const TunicwildRelations = relations(Tunicwild, ({ one }) => ({ + member: one(Member, { + fields: [Tunicwild.memberDiscord], + references: [Member.discord], + }), +})); + export const Word = sqliteTable("Word", { id: integer().primaryKey({ autoIncrement: true }), title: text().notNull(), diff --git a/src/database/seed.ts b/src/database/seed.ts index 95dc4fe..281ad28 100644 --- a/src/database/seed.ts +++ b/src/database/seed.ts @@ -1,5 +1,11 @@ import { faker } from "@faker-js/faker"; -import { ActionFactory, ActionItemFactory, MemberFactory, MotionFactory, SightFactory, SoundFactory, WordFactory } from "./factories"; +import { ActionFactory, ActionItemFactory, MemberFactory, MotionFactory, SightFactory, SoundFactory, TunicwildFactory, WordFactory } from "./factories"; +import { writeFile } from "fs/promises"; +import { join } from "path"; + +if (!process.env.TUNICWILDS_UPLOAD_DIRECTORY) { + throw new Error("TUNICWILDS_UPLOAD_DIRECTORY not set"); +} export default async function seed() { const members = await new MemberFactory().count(20).create(); @@ -43,4 +49,18 @@ export default async function seed() { await new WordFactory().count(10).create({ memberDiscord: () => faker.helpers.arrayElement(members.map((member) => member.discord)), }); + + const tunicwilds = await new TunicwildFactory().count(10).create({ + memberDiscord: () => faker.helpers.arrayElement(members.map((member) => member.discord)), + }); + + const placeholderAudioResponse = await fetch("https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3"); + const placeholderAudio = await placeholderAudioResponse.bytes(); + + for (const tunicwild of tunicwilds) { + await writeFile( + join(process.env.TUNICWILDS_UPLOAD_DIRECTORY!, tunicwild.id.toString()), + placeholderAudio, + ); + } } diff --git a/src/database/utils.ts b/src/database/utils.ts new file mode 100644 index 0000000..c1f1d12 --- /dev/null +++ b/src/database/utils.ts @@ -0,0 +1,14 @@ +import { SQL, sql } from "drizzle-orm"; +import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; + +export function lower(column: SQLiteColumn | string): SQL { + return sql`lower(${column})`; +} + +export function upper(column: SQLiteColumn | string): SQL { + return sql`upper(${column})`; +} + +export function like(column: SQLiteColumn | string, value: string): SQL { + return sql`${column} LIKE ${value}`; +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index dde5b8f..6332a96 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,7 +9,11 @@ const checkHmacForApi = defineMiddleware(async (context, next) => { return next(); } - if (context.url.pathname.startsWith("/api/action") && context.request.method === "GET") { + if ( + ( + context.url.pathname.startsWith("/api/action") || + context.url.pathname.startsWith("/api/tunicwild") + ) && context.request.method === "GET") { return next(); } @@ -59,6 +63,10 @@ const serveUploadedFilesInDev = defineMiddleware(async (context, next) => { return new Response("SOUNDS_UPLOAD_DIRECTORY not set", { status: 500 }); } + if (!process.env.TUNICWILDS_RENDERED_DIRECTORY) { + return new Response("TUNICWILDS_RENDERED_DIRECTORY not set", { status: 500 }); + } + if (!process.env.WORDS_UPLOAD_DIRECTORY) { return new Response("WORDS_UPLOAD_DIRECTORY not set", { status: 500 }); } @@ -66,6 +74,7 @@ const serveUploadedFilesInDev = defineMiddleware(async (context, next) => { const rewrites = [ ["/sights-uploads/", process.env.SIGHTS_UPLOAD_DIRECTORY + "/"], ["/sounds-uploads/", process.env.SOUNDS_UPLOAD_DIRECTORY + "/"], + ["/tunicwilds-rendered/", process.env.TUNICWILDS_RENDERED_DIRECTORY + "/"], ["/words-uploads/", process.env.WORDS_UPLOAD_DIRECTORY + "/"], ] as const; diff --git a/src/pages/api/tunicwilds/games.ts b/src/pages/api/tunicwilds/games.ts new file mode 100644 index 0000000..d8c2f96 --- /dev/null +++ b/src/pages/api/tunicwilds/games.ts @@ -0,0 +1,18 @@ +import type { APIRoute } from "astro"; +import db from "../../../database/db"; +import { Tunicwild } from "../../../database/schema"; +import { jsonResponse } from "../../../server/responses"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + return jsonResponse( + await db + .select({ + game: Tunicwild.game + }) + .from(Tunicwild) + .groupBy(Tunicwild.game) + .orderBy(Tunicwild.game), + ); +}; diff --git a/src/pages/api/tunicwilds/index.ts b/src/pages/api/tunicwilds/index.ts new file mode 100644 index 0000000..b9b983a --- /dev/null +++ b/src/pages/api/tunicwilds/index.ts @@ -0,0 +1,251 @@ +import { join } from "node:path"; +import { LibsqlError } from "@libsql/client"; +import type { APIRoute } from "astro"; +import { and, eq, type InferSelectModel, type SQLWrapper } from "drizzle-orm"; +import { jsonError, jsonResponse } from "../../../server/responses"; +import db, { retryIfDbBusy } from "../../../database/db"; +import { Member, Tunicwild } from "../../../database/schema"; +import { paginationQuery, parseNumberCursor } from "../../../server/pagination"; +import { writeBlobToFile } from "../../../server/webApi"; +import { lower } from "../../../database/utils"; + +export const prerender = false; + +export const GET: APIRoute = async ({ url }) => { + const conditions: SQLWrapper[] = []; + const params = url.searchParams; + + const game = params.get("game"); + if (game) + conditions.push(eq(lower(Tunicwild.game), lower(game))); + + const composer = params.get("composer"); + if (composer) + conditions.push(eq(lower(Tunicwild.composer), lower(composer))); + + const title = params.get("title"); + if (title) + conditions.push(eq(lower(Tunicwild.title), lower(title))); + + const releaseDate = params.get("releaseDate"); + if (releaseDate) { + const date = new Date(releaseDate); + if (isNaN(date.getTime())) + return jsonError("Invalid release date"); + conditions.push(eq(Tunicwild.releaseDate, date)); + } + + return paginationQuery( + params, + Tunicwild, + "id", + parseNumberCursor, + ...conditions, + ); +} + +export const POST: APIRoute = async (context) => { + if (!process.env.TUNICWILDS_UPLOAD_URL && !process.env.TUNICWILDS_UPLOAD_DIRECTORY) { + return jsonError("TUNICWILDS_UPLOAD_* not set", 500); + } + + let formData: FormData; + try { + formData = await context.request.formData(); + } catch { + return jsonError("Request body must be form data"); + } + + const discord = formData.get("discord"); + const composer = formData.get("composer"); + const title = formData.get("title"); + const game = formData.get("game"); + const releaseDate = formData.get("releaseDate"); + const officialLink = formData.get("officialLink"); + const file = formData.get("file") as File; + + if (typeof discord !== "string" || !discord) { + return jsonError("Member discord is required"); + } + if (typeof composer !== "string" || !composer.trim()) { + return jsonError("Composer is required"); + } + if (typeof title !== "string" || !title.trim()) { + return jsonError("Title is required"); + } + if (typeof game !== "string" || !game.trim()) { + return jsonError("Game is required"); + } + if (typeof releaseDate !== "string" || !releaseDate.trim()) { + return jsonError("Release date is required"); + } + if (typeof officialLink !== "string" || !officialLink.trim()) { + return jsonError("Official link is required"); + } + if (!(file instanceof File) || file.size === 0) { + return jsonError("Valid audio file is required"); + } + + // Parse and validate release date + const releaseDateParsed = new Date(releaseDate); + if (isNaN(releaseDateParsed.getTime())) { + return jsonError("Invalid release date format. Use YYYY-MM-DD or ISO format"); + } + if (releaseDateParsed.getTime() > Date.now()) { + return jsonError("Release date cannot be in the future"); + } + + // Length validation with better limits + const MAX_LENGTH = 2 ** 10; + if (composer.length > MAX_LENGTH) { + return jsonError(`Composer name too long (max ${MAX_LENGTH} characters)`); + } + if (title.length > MAX_LENGTH) { + return jsonError(`Title too long (max ${MAX_LENGTH} characters)`); + } + if (game.length > MAX_LENGTH) { + return jsonError(`Game name too long (max ${MAX_LENGTH} characters)`); + } + + // File validation with size limits + const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + if (file.size > MAX_FILE_SIZE) { + return jsonError("File too large (max 50MB)"); + } + + const member = await db + .select() + .from(Member) + .where(eq(Member.discord, discord)) + .get(); + + if (member == null) { + return jsonError("Member does not exist"); + } + + if (member.deleted) { + return jsonError("Member has been deleted"); + } + + // More robust file type checking + const allowedExtensions = [".ogg", ".opus", ".wav", ".mp3"]; + const allowedMimeTypes = ["audio/ogg", "audio/opus", "audio/wav", "audio/mpeg", "audio/mp3"]; + + const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); + const isValidExtension = allowedExtensions.includes(fileExtension); + const isValidMimeType = allowedMimeTypes.includes(file.type.toLowerCase()); + + if (!isValidExtension && !isValidMimeType) { + return jsonError("File must be an OGG, Opus, WAV, or MP3 file"); + } + + // Check for duplicates with case-insensitive comparison + const existingTunicwild = await db + .select() + .from(Tunicwild) + .where( + and( + eq(lower(Tunicwild.title), lower(title.trim())), + eq(lower(Tunicwild.game), lower(game.trim())) + ) + ) + .get(); + + if (existingTunicwild) { + return jsonError(`A song with title "${title}" already exists in game "${game}"`); + } + + // Store to DB + let tunicwild: InferSelectModel; + try { + tunicwild = await retryIfDbBusy(() => + db + .insert(Tunicwild) + .values({ + memberDiscord: discord, + composer: composer.trim(), + title: title.trim(), + game: game.trim(), + releaseDate: releaseDateParsed, + officialLink: officialLink.trim(), + }) + .returning() + .get() + ); + } catch (error) { + if (error instanceof LibsqlError) { + if (error.code === "SQLITE_CONSTRAINT_UNIQUE") { + return jsonError("A song with this title and game already exists"); + } + if (error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { + return jsonError("Invalid foreign key constraint"); + } + } + console.error("Database error:", error); + return jsonError("Failed to save song to database", 500); + } + + // Upload files with better error handling + try { + if (process.env.TUNICWILDS_UPLOAD_DIRECTORY) { + await writeBlobToFile( + join(process.env.TUNICWILDS_UPLOAD_DIRECTORY, tunicwild.id.toString()), + file, + ); + } else if (process.env.TUNICWILDS_UPLOAD_URL) { + const uploadResponse = await fetch( + `${process.env.TUNICWILDS_UPLOAD_URL}/upload/${tunicwild.id}`, + { + method: "PUT", + body: file, + headers: { + "Content-Length": file.size.toString(), + }, + } + ); + + if (!uploadResponse.ok) { + // If timed out, retry a few times + if (uploadResponse.status === 408 || uploadResponse.status === 504) { + for (let i = 0; i < 3; i++) { + const retryResponse = await fetch( + `${process.env.TUNICWILDS_UPLOAD_URL}/upload/${tunicwild.id}`, + { + method: "PUT", + body: file, + headers: { + "Content-Length": file.size.toString(), + }, + } + ); + if (retryResponse.ok) + break; + + if (i === 2) { + // Rollback database entry if all retries fail + await db.delete(Tunicwild).where(eq(Tunicwild.id, tunicwild.id)); + return jsonError(`Failed to upload file after retries: ${retryResponse.statusText}`, 500); + } + } + } else { + // Rollback database entry if file upload fails + await db.delete(Tunicwild).where(eq(Tunicwild.id, tunicwild.id)); + return jsonError(`Failed to upload file: ${uploadResponse.statusText}`, 500); + } + } + } else { + return jsonError("", 500); + } + } catch (error) { + console.error("File upload error:", error); + // Rollback database entry if file upload fails + try { + await db.delete(Tunicwild).where(eq(Tunicwild.id, tunicwild.id)); + } catch (rollbackError) { + console.error("Failed to rollback database entry:", rollbackError); + } + return jsonError("Failed to upload audio file", 500); + } + + return jsonResponse(tunicwild); +}; diff --git a/src/pages/corp/sso/perform.ts b/src/pages/corp/sso/perform.ts index 73e2df5..66ca857 100644 --- a/src/pages/corp/sso/perform.ts +++ b/src/pages/corp/sso/perform.ts @@ -7,6 +7,12 @@ import { jsonError } from "../../../server/responses"; export const prerender = false; +declare global { + interface SessionData { + memberDiscord?: string; + } +} + export const POST: APIRoute = async (context) => { let formData: FormData; try { diff --git a/src/pages/things.astro b/src/pages/things.astro index 635c38b..53ae30c 100644 --- a/src/pages/things.astro +++ b/src/pages/things.astro @@ -12,6 +12,7 @@ import Layout from "../layouts/Layout.astro"; features/content

diff --git a/src/pages/tunicwilds/index.astro b/src/pages/tunicwilds/index.astro new file mode 100644 index 0000000..25d3392 --- /dev/null +++ b/src/pages/tunicwilds/index.astro @@ -0,0 +1,25 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import TunicwildsApp from "../../components/Tunicwilds/Tunicwilds.svelte"; +import db from "../../database/db"; +import { Tunicwild } from "../../database/schema"; + +export const prerender = false; + +const songList = await db + .select({ + id: Tunicwild.id, + composer: Tunicwild.composer, + game: Tunicwild.game, + officialLink: Tunicwild.officialLink, + title: Tunicwild.title, + }) + .from(Tunicwild); +--- + + + + diff --git a/src/pages/tunicwilds/today.ts b/src/pages/tunicwilds/today.ts new file mode 100644 index 0000000..5f79e49 --- /dev/null +++ b/src/pages/tunicwilds/today.ts @@ -0,0 +1,190 @@ +import type { APIRoute } from "astro"; +import { jsonError, jsonResponse } from "../../server/responses"; +import db from "../../database/db"; +import { Tunicwild } from "../../database/schema"; +import { eq, type InferSelectModel } from "drizzle-orm"; +import { readFile } from "node:fs/promises"; + +export const prerender = false; + +if (!process.env.TUNICWILDS_DAILY_FILE) { + throw new Error("TUNICWILDS_DAILY_FILE not set"); +} + +declare global { + interface SessionData { + tunicwilds?: Record; + } +} + +interface DailyInfo { + active: { + audioFilenames: string[]; + date: string; + id: number; + }[]; + recentIds: number[]; + startDate: string; +} + +export const GET: APIRoute = async (context) => { + const date = new Date(Number.parseInt(context.url.searchParams.get("timestamp") ?? "", 10)); + + if (Number.isNaN(date.getTime())) { + return jsonError("Invalid timestamp"); + } + + let dailyInfo: DailyInfo; + try { + dailyInfo = JSON.parse(await readFile(process.env.TUNICWILDS_DAILY_FILE!, "utf8")); + } catch (error) { + console.error("Failed to read or parse daily tunicwilds file:", error); + return jsonError("Failed to read daily tunicwilds file"); + } + const tunicwildDateString = date.toISOString().slice(0, 10); + const tunicwildInfo = dailyInfo.active.find((info) => info.date === tunicwildDateString); + + if (tunicwildInfo == null) { + return jsonError("Invalid date. Check if your system clock is set correctly"); + } + + const tunicwild = await db + .select() + .from(Tunicwild) + .where(eq(Tunicwild.id, tunicwildInfo.id)) + .get(); + + if (tunicwild == null) { + throw new Error("Missing daily tunicwild in database"); + } + + const tunicwildsSession = context.locals.session.data.tunicwilds?.[tunicwildDateString] ?? { + guesses: [], + result: null, + }; + const responseBody: { + fourFourFiveEnabled: boolean; + session: Required["tunicwilds"][string]; + tunicwild: Partial> & { audioUrl: string }; + } = { + fourFourFiveEnabled: context.locals.session.data.memberDiscord != null, + session: tunicwildsSession, + tunicwild: { + audioUrl: `/tunicwilds-rendered/${tunicwildInfo.audioFilenames[ + tunicwildsSession.result != null + ? tunicwildInfo.audioFilenames.length - 1 + : tunicwildsSession.guesses.length + ]}`, + }, + }; + + if (tunicwildsSession.result != null) { + Object.assign(responseBody.tunicwild, tunicwild); + delete responseBody.tunicwild.memberDiscord; + } else { + const guessCount = tunicwildsSession.guesses.length; + + if (guessCount >= 2) { + responseBody.tunicwild.releaseDate = tunicwild.releaseDate; + } + + if (guessCount >= 4) { + responseBody.tunicwild.game = tunicwild.game; + } + } + + return jsonResponse(responseBody); +}; + +export const POST: APIRoute = async (context) => { + const params: Partial> = await context.request.json(); + + if ( + typeof params.timestamp !== "number" || + (params.guess != null && typeof params.guess !== "number") + ) { + return jsonError("Invalid body"); + } + + const date = new Date(params.timestamp); + + if (Number.isNaN(date.getTime())) { + return jsonError("Invalid timestamp"); + } + + const dailyInfo: DailyInfo = JSON.parse(await readFile(process.env.TUNICWILDS_DAILY_FILE!, "utf8")); + const tunicwildDateString = date.toISOString().slice(0, 10); + const tunicwildId = dailyInfo.active.find((info) => info.date === tunicwildDateString)?.id; + + if (tunicwildId == null) { + return jsonError("Invalid date. Check if your system clock is set correctly"); + } + + let tunicwildsSession = context.locals.session.data.tunicwilds?.[tunicwildDateString]; + + if (tunicwildsSession == null) { + context.locals.session.data.tunicwilds ??= {}; + tunicwildsSession = context.locals.session.data.tunicwilds[tunicwildDateString] = { + guesses: [], + result: null, + }; + } + + if (tunicwildsSession.result != null || tunicwildsSession.guesses.length >= 6) { + return jsonError("Tunicwild has already been completed"); + } + + if (params.guess == null) { + tunicwildsSession.guesses.push(null); + } else { + const tunicwild = await db + .select() + .from(Tunicwild) + .where(eq(Tunicwild.id, tunicwildId)) + .get(); + + if (tunicwild == null) { + throw new Error("Missing daily tunicwild in database"); + } + + const guessedTunicwild = await db + .select() + .from(Tunicwild) + .where(eq(Tunicwild.id, params.guess)) + .get(); + + if (guessedTunicwild == null) { + return jsonError("Invalid guess"); + } + + tunicwildsSession.guesses.push({ + id: params.guess, + result: guessedTunicwild.id === tunicwild.id + ? "correct" + : guessedTunicwild.game === tunicwild.game + ? "correctGame" + : guessedTunicwild.title.toLowerCase() === tunicwild.title.toLowerCase() + ? "correctTitle" + : "incorrect", + }); + } + + if (params.guess === tunicwildId) { + tunicwildsSession.result = true; + } else if (tunicwildsSession.guesses.length >= 6) { + tunicwildsSession.result = false; + } + + // TODO delete older games from session + + const url = new URL(context.url); + url.searchParams.set("timestamp", params.timestamp.toString()); + + return context.redirect(url.toString(), 302); +}; diff --git a/src/server/session.ts b/src/server/session.ts index 32aada1..2768358 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -14,10 +14,6 @@ declare global { } } -export interface SessionData { - memberDiscord?: string; -} - const cookieName = "latex_session"; const maxAgeMs = 1000 * 60 * 60 * 24 * 30;