diff --git a/.env.example b/.env.example index 1693ebd..0684de7 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ PORT=3000 DATABASE_URL="mysql://root:password@localhost/FurryPlace" + +# Only required for development +SHADOW_DATABASE_URL="mysql://root:password@localhost/openplace_shadow" + JWT_SECRET="your-secret-key" # Google OAuth diff --git a/prisma/migrations/20251001122504_init/migration.sql b/prisma/migrations/20251001122504_init/migration.sql new file mode 100644 index 0000000..79cea2e --- /dev/null +++ b/prisma/migrations/20251001122504_init/migration.sql @@ -0,0 +1,198 @@ +-- CreateTable +CREATE TABLE `User` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `discord` VARCHAR(191) NULL, + `country` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NULL, + `passwordHash` VARCHAR(191) NOT NULL, + `banned` BOOLEAN NOT NULL DEFAULT false, + `timeoutUntil` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `needsPhoneVerification` BOOLEAN NOT NULL DEFAULT false, + `isCustomer` BOOLEAN NOT NULL DEFAULT false, + `role` VARCHAR(191) NOT NULL DEFAULT 'user', + `pixelsPainted` INTEGER NOT NULL DEFAULT 0, + `droplets` INTEGER NOT NULL DEFAULT 0, + `maxCharges` DOUBLE NOT NULL DEFAULT 20, + `currentCharges` DOUBLE NOT NULL DEFAULT 20, + `chargesCooldownMs` INTEGER NOT NULL DEFAULT 30000, + `chargesLastUpdatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `extraColorsBitmap` INTEGER NOT NULL DEFAULT 0, + `flagsBitmap` LONGBLOB NULL, + `equippedFlag` INTEGER NOT NULL DEFAULT 0, + `showLastPixel` BOOLEAN NOT NULL DEFAULT true, + `maxFavoriteLocations` INTEGER NOT NULL DEFAULT 15, + `picture` VARCHAR(191) NULL, + `level` DOUBLE NOT NULL DEFAULT 1, + `allianceId` INTEGER NULL, + `allianceRole` VARCHAR(191) NOT NULL DEFAULT 'member', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Alliance` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `description` VARCHAR(191) NULL, + `hqLatitude` DOUBLE NULL, + `hqLongitude` DOUBLE NULL, + `pixelsPainted` INTEGER NOT NULL DEFAULT 0, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `Alliance_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `BannedUser` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `allianceId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `BannedUser_userId_allianceId_key`(`userId`, `allianceId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AllianceInvite` ( + `id` VARCHAR(191) NOT NULL, + `allianceId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `FavoriteLocation` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `name` VARCHAR(191) NOT NULL DEFAULT '', + `latitude` DOUBLE NOT NULL, + `longitude` DOUBLE NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Tile` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `x` INTEGER NOT NULL, + `y` INTEGER NOT NULL, + `imageData` LONGBLOB NULL, + + UNIQUE INDEX `Tile_x_y_key`(`x`, `y`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Pixel` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `tileX` INTEGER NOT NULL, + `tileY` INTEGER NOT NULL, + `x` INTEGER NOT NULL, + `y` INTEGER NOT NULL, + `colorId` INTEGER NOT NULL, + `paintedBy` INTEGER NOT NULL, + `paintedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `Pixel_tileX_tileY_x_y_key`(`tileX`, `tileY`, `x`, `y`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Region` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `cityId` INTEGER NOT NULL, + `name` VARCHAR(191) NOT NULL, + `number` INTEGER NOT NULL, + `countryId` INTEGER NOT NULL, + + UNIQUE INDEX `Region_cityId_key`(`cityId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ProfilePicture` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `url` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Session` ( + `id` VARCHAR(191) NOT NULL, + `userId` INTEGER NOT NULL, + `expiresAt` DATETIME(3) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Ticket` ( + `id` VARCHAR(191) NOT NULL, + `userId` INTEGER NOT NULL, + `reportedUserId` INTEGER NOT NULL, + `latitude` DOUBLE NOT NULL, + `longitude` DOUBLE NOT NULL, + `zoom` DOUBLE NOT NULL, + `reason` VARCHAR(191) NOT NULL, + `notes` VARCHAR(191) NOT NULL, + `image` VARCHAR(191) NOT NULL, + `resolved` BOOLEAN NOT NULL DEFAULT false, + `severe` BOOLEAN NOT NULL DEFAULT false, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `UserNote` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `reportedUserId` INTEGER NOT NULL, + `content` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `User` ADD CONSTRAINT `User_allianceId_fkey` FOREIGN KEY (`allianceId`) REFERENCES `Alliance`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `BannedUser` ADD CONSTRAINT `BannedUser_allianceId_fkey` FOREIGN KEY (`allianceId`) REFERENCES `Alliance`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AllianceInvite` ADD CONSTRAINT `AllianceInvite_allianceId_fkey` FOREIGN KEY (`allianceId`) REFERENCES `Alliance`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FavoriteLocation` ADD CONSTRAINT `FavoriteLocation_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Pixel` ADD CONSTRAINT `Pixel_paintedBy_fkey` FOREIGN KEY (`paintedBy`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Pixel` ADD CONSTRAINT `Pixel_tileX_tileY_fkey` FOREIGN KEY (`tileX`, `tileY`) REFERENCES `Tile`(`x`, `y`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Ticket` ADD CONSTRAINT `Ticket_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Ticket` ADD CONSTRAINT `Ticket_reportedUserId_fkey` FOREIGN KEY (`reportedUserId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `UserNote` ADD CONSTRAINT `UserNote_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `UserNote` ADD CONSTRAINT `UserNote_reportedUserId_fkey` FOREIGN KEY (`reportedUserId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251001122622_seasons/migration.sql b/prisma/migrations/20251001122622_seasons/migration.sql new file mode 100644 index 0000000..dee04e0 --- /dev/null +++ b/prisma/migrations/20251001122622_seasons/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE `Pixel` ADD COLUMN `season` INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE `Tile` ADD COLUMN `season` INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20251001122623_tile_imagedata/migration.sql b/prisma/migrations/20251001122623_tile_imagedata/migration.sql new file mode 100644 index 0000000..8d67b43 --- /dev/null +++ b/prisma/migrations/20251001122623_tile_imagedata/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - A unique constraint covering the columns `[season,tileX,tileY,x,y]` on the table `Pixel` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[season,x,y]` on the table `Tile` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE `Pixel` DROP FOREIGN KEY `Pixel_tileX_tileY_fkey`; + +-- DropIndex +DROP INDEX `Pixel_season_tileX_tileY_idx` ON `Pixel`; + +-- DropIndex +DROP INDEX `Pixel_tileX_tileY_x_y_key` ON `Pixel`; + +-- DropIndex +DROP INDEX `Tile_season_x_y_idx` ON `Tile`; + +-- DropIndex +DROP INDEX `Tile_x_y_key` ON `Tile`; + +-- CreateIndex +CREATE UNIQUE INDEX `Pixel_season_tileX_tileY_x_y_key` ON `Pixel`(`season`, `tileX`, `tileY`, `x`, `y`); + +-- CreateIndex +CREATE UNIQUE INDEX `Tile_season_x_y_key` ON `Tile`(`season`, `x`, `y`); + +-- AddForeignKey +ALTER TABLE `Pixel` ADD CONSTRAINT `Pixel_season_tileX_tileY_fkey` FOREIGN KEY (`season`, `tileX`, `tileY`) REFERENCES `Tile`(`season`, `x`, `y`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..592fc0b --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "mysql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 358c031..26c0b8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,6 +5,7 @@ generator client { datasource db { provider = "mysql" url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") } model User { @@ -88,16 +89,18 @@ model FavoriteLocation { model Tile { id Int @id @default(autoincrement()) + season Int @default(0) x Int y Int imageData Bytes? pixels Pixel[] - @@unique([x, y]) + @@unique([season, x, y]) } model Pixel { id Int @id @default(autoincrement()) + season Int @default(0) tileX Int tileY Int x Int @@ -105,10 +108,10 @@ model Pixel { colorId Int paintedBy Int user User @relation(fields: [paintedBy], references: [id]) - tile Tile @relation(fields: [tileX, tileY], references: [x, y]) + tile Tile @relation(fields: [season, tileX, tileY], references: [season, x, y]) paintedAt DateTime @default(now()) - @@unique([tileX, tileY, x, y]) + @@unique([season, tileX, tileY, x, y]) } model Region { diff --git a/src/index.ts b/src/index.ts index 533e6c1..ebc14f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ dotenv.config(); const app = new App({ noMatchHandler: async (_req, res) => { - const html = await fs.readFile("./frontend/404.html", "utf-8"); + const html = await fs.readFile("./frontend/404.html", "utf8"); return res.status(404) .setHeader("Content-Type", "text/html") .send(html); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index ab110d5..6d57e65 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -11,7 +11,7 @@ export async function authMiddleware(req: any, res: any, next: any) { .json({ error: "Unauthorized", status: 401 }); } - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, JWT_SECRET!) as any; if (!decoded.userId || !decoded.sessionId) { return res.status(401) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 9872115..5475ec6 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -181,7 +181,7 @@ export default function (app: App) { exp: Math.floor(session.expiresAt.getTime() / 1000), iat: Math.floor(Date.now() / 1000) }, - JWT_SECRET + JWT_SECRET! ); res.setHeader("Set-Cookie", [ @@ -201,7 +201,7 @@ export default function (app: App) { if (req.user?.sessionId) { await prisma.session.delete({ where: { id: req.user.sessionId } - }) + }); } res.setHeader("Set-Cookie", [ diff --git a/src/routes/leaderboard.ts b/src/routes/leaderboard.ts index 01a3fda..bc8c3c3 100644 --- a/src/routes/leaderboard.ts +++ b/src/routes/leaderboard.ts @@ -56,7 +56,7 @@ export default function (app: App) { } // TODO: calculate country pixel data const mockCountries = [ - { id: 235, pixelsPainted: 1_234 } + { id: 235, pixelsPainted: 1234 } ]; return res.json(mockCountries); diff --git a/src/routes/pixel.ts b/src/routes/pixel.ts index d283a79..0db022b 100644 --- a/src/routes/pixel.ts +++ b/src/routes/pixel.ts @@ -66,7 +66,7 @@ export default function (app: App) { .json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST)); } - const imageBuffer = await pixelService.generateTileImage(tileX, tileY); + const imageBuffer = await pixelService.getTileImage(tileX, tileY); res.setHeader("Content-Type", "image/png"); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); diff --git a/src/services/pixel.ts b/src/services/pixel.ts index 4268e67..0d0ed97 100644 --- a/src/services/pixel.ts +++ b/src/services/pixel.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "@prisma/client"; +import { Prisma, PrismaClient } from "@prisma/client"; import { createCanvas } from "@napi-rs/canvas"; import { checkColorUnlocked, COLOR_PALETTE } from "../utils/colors.js"; import { calculateChargeRecharge } from "../utils/charges.js"; @@ -98,7 +98,7 @@ export class PixelService { }; } - async getPixelInfo(tileX: number, tileY: number, x: number, y: number): Promise { + async getPixelInfo(tileX: number, tileY: number, x: number, y: number, season: number = 0): Promise { let paintedBy = { id: 0, name: "", @@ -109,7 +109,7 @@ export class PixelService { const pixel = await this.prisma.pixel.findUnique({ where: { - tileX_tileY_x_y: { tileX, tileY, x, y } + season_tileX_tileY_x_y: { season, tileX, tileY, x, y } }, include: { user: { @@ -118,7 +118,7 @@ export class PixelService { } }); - if (pixel) { + if (pixel && pixel.season === season) { paintedBy = { id: pixel.user.id, name: pixel.user.name, @@ -133,27 +133,74 @@ export class PixelService { return { paintedBy, region }; } - async generateTileImage(tileX: number, tileY: number): Promise { + async getTileImage(tileX: number, tileY: number, season: number = 0): Promise { + const tile = await this.prisma.tile.findUnique({ + where: { + season_x_y: { + season, + x: tileX, + y: tileY + } + } + }); + + if (!tile) { + return Buffer.from([]); + } + + return tile.imageData + ? Buffer.from(tile.imageData) + : await this.updatePixelTile(tileX, tileY, season); + } + + async updatePixelTile(tileX: number, tileY: number, season: number = 0): Promise { const canvas = createCanvas(1000, 1000); const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, 1000, 1000); + const pixels = await this.prisma.pixel.findMany({ - where: { tileX, tileY } + where: { tileX, tileY, season } }); for (const pixel of pixels) { const color = COLOR_PALETTE[pixel.colorId]; if (color) { - const [r, g, b] = color.rgb; - ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; - ctx.fillRect(pixel.x, pixel.y, 1, 1); + if (pixel.colorId === 0) { + ctx.clearRect(pixel.x, pixel.y, 1, 1); + } else { + const [r, g, b] = color.rgb; + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + ctx.fillRect(pixel.x, pixel.y, 1, 1); + } } } - return canvas.toBuffer("image/png"); + const imageData = canvas.toBuffer("image/png"); + + await this.prisma.tile.upsert({ + where: { + season_x_y: { + season, + x: tileX, + y: tileY + } + }, + create: { + season, + x: tileX, + y: tileY, + imageData + }, + update: { + imageData + } + }); + + return imageData; } - async paintPixels(userId: number, input: PaintPixelsInput): Promise { + async paintPixels(userId: number, input: PaintPixelsInput, season: number = 0): Promise { const { tileX, tileY, colors, coords } = input; if (!colors || !coords || !Array.isArray(colors) || !Array.isArray(coords)) { @@ -252,30 +299,35 @@ export class PixelService { } await this.prisma.tile.upsert({ - where: { x_y: { x: tileX, y: tileY } }, - create: { x: tileX, y: tileY }, + where: { season_x_y: { season, x: tileX, y: tileY } }, + create: { season, x: tileX, y: tileY }, update: {} }); const now = new Date(); if (validPixels.length > 0) { - const timestamp = now.toISOString().slice(0, 19).replace("T", " "); - const values = validPixels.map(pixel => - `(${Number(tileX)}, ${Number(tileY)}, ${Number(pixel.x)}, ${Number(pixel.y)}, ${Number(pixel.colorId)}, ${Number(userId)}, '${timestamp}')` - ) - .join(", "); + const values = validPixels.map(pixel => ({ + season, + tileX, + tileY, + x: pixel.x, + y: pixel.y, + colorId: pixel.colorId, + paintedBy: userId, + paintedAt: now + })); - const bulkQuery = ` - INSERT INTO Pixel (tileX, tileY, x, y, colorId, paintedBy, paintedAt) - VALUES ${values} + await this.prisma.$executeRaw` + INSERT INTO Pixel (season, tileX, tileY, x, y, colorId, paintedBy, paintedAt) + VALUES ${Prisma.join(values.map(v => + Prisma.sql`(${v.season}, ${v.tileX}, ${v.tileY}, ${v.x}, ${v.y}, ${v.colorId}, ${v.paintedBy}, ${v.paintedAt})` + ))} ON DUPLICATE KEY UPDATE colorId = VALUES(colorId), paintedBy = VALUES(paintedBy), paintedAt = VALUES(paintedAt) `; - - await this.prisma.$executeRawUnsafe(bulkQuery); } const newCharges = Math.max(0, user.currentCharges - totalChargeCost); @@ -320,6 +372,8 @@ export class PixelService { }); } + await this.updatePixelTile(tileX, tileY, season); + return { painted }; } }