first commit

This commit is contained in:
Toby Kohlhagen
2025-10-01 13:06:10 +10:00
committed by Adam Demasi
commit b95af1940c
44 changed files with 20204 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# https://editorconfig.org/
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.y{,a}ml]
indent_style = space
[{Makefile,*.mk}]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
DATABASE_URL="mysql://root:password@localhost/openplace"
JWT_SECRET="your-secret-key"
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "chariz",
"settings": {
"react": {
"version": "18"
}
},
"rules": {
"unicorn/switch-case-braces": ["error", "avoid"]
}
}
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
.env
.eslintcache
.pnpm-store/
*.db
*.log
node_modules/
+3
View File
@@ -0,0 +1,3 @@
[submodule "frontend"]
path = frontend
url = https://github.com/openplaceteam/frontend
+17
View File
@@ -0,0 +1,17 @@
https://*:8080 {
root * ./frontend
try_files {path} {path}/index.html
handle {
@file_exists file
handle @file_exists {
file_server
}
@not_file_exists not file
handle @not_file_exists {
reverse_proxy localhost:3000
}
}
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+35
View File
@@ -0,0 +1,35 @@
# openplace
Openplace (styled lowercase) is a free unofficial open source backend for [wplace.](https://wplace.live) We aim to give the freedom and flexibility for all users to be able to make their own private wplace experience for themselves, their friends, or even their community.
> [!WARNING]
> This is a work-in-progress. Expect unfinished features and bugs. Please help us by posting issues in #help-n-support on our [Discord server](https://discord.gg/ZRC4DnP9Z2) or by contributing pull requests. Thanks!
## macOS
### Getting Started
This is where you will be preparing your machine to run openplace.
1. install brew, node and git
2. run `git clone --recurse-submodules https://github.com/openplaceteam/openplace`
3. cd into the openplace directory
4. run ``npm i && brew install mariadb caddy``
5. brew will then spit out a command to inform you on how to start it. if it doesn't, run `brew services start mariadb && brew services start caddy`
#### Configuring and building the database
1. run `sudo mysql_secure_installation`
2. it will then ask you for your current root password. just hit enter
3. hit 'n' when it asks you to switch to unix_socket authentication
4. hit 'y' when it asks you to change your root password. for demonstration purposes, i have made my password 'password'. (do not do this)
5. when it asks you to remove anonymous users, hit 'y'
6. it will ask you if you want to disallow remote root logins, this is entirely up to you.
7. hit 'y' when it asks you to remove the test database
8. finally, hit 'y' when it asks you to reload configuration.
9. copy the .env.example file on the root directory and rename it to .env where `root:password` is, replace `password` with your password.
10. you can now run `npx prisma migrate deploy`
11. next, `npx prisma generate`
12. NEXT, `npx prisma db push`
13. then you can run `npm run dev`
14. in another terminal, cd to the same root directory and run `caddy run --config Caddyfile`
#### Spinning up your server
You will be required to configure an SSL certificate if you plan to use this in production. However, if you are only using this with you and your friends, you can simply navigate to `https://{IP}:8080` NOTE: openplace is only hosted over HTTPS. you will run into HTTP error 400 if you attempt to load the website over HTTP.
#### Updating your database
In the event that the database schematic changes, you simply need to run `npm run db:push` to update your database schema.
Submodule
+1
Submodule frontend added at 3fc3dc12cb
+8801
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
{
"name": "openplace",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src",
"build": "tsc",
"start": "node dist",
"seed": "tsx src/seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint --ext .js,.ts src/",
"lint:fix": "pnpm -s lint --fix",
"db:push": "prisma db push && prisma generate",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.1",
"@tsconfig/strictest": "^2.0.5",
"@types/node": "^24.3.1",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.57.1",
"eslint-config-chariz": "^1.6.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^47.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"supertest": "^7.1.4",
"typescript": "^5.9.2",
"vitest": "^1.6.1"
},
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.80",
"@prisma/client": "^6.16.2",
"@tinyhttp/app": "^3.0.1",
"@tinyhttp/cookie-parser": "^2.0.6",
"@tinyhttp/cors": "^2.0.1",
"@types/cookie-parser": "^1.4.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"bcryptjs": "^3.0.2",
"dotenv": "^17.2.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"mysql2": "^3.15.1",
"prisma": "^6.16.2",
"tsx": "^4.20.6",
"uuid": "^13.0.0"
}
}
+5481
View File
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
discord String?
country String
email String? @unique
passwordHash String
banned Boolean @default(false)
timeoutUntil DateTime @default(now())
needsPhoneVerification Boolean @default(false)
isCustomer Boolean @default(false)
role String @default("user")
pixelsPainted Int @default(0)
droplets Int @default(0)
maxCharges Float @default(20)
currentCharges Float @default(20)
chargesCooldownMs Int @default(30000)
chargesLastUpdatedAt DateTime @default(now())
extraColorsBitmap Int @default(0)
flagsBitmap Bytes?
equippedFlag Int @default(0)
showLastPixel Boolean @default(true)
maxFavoriteLocations Int @default(15)
picture String?
level Float @default(1)
allianceId Int?
allianceRole String @default("member")
alliance Alliance? @relation(fields: [allianceId], references: [id])
paintedPixels Pixel[]
favoriteLocations FavoriteLocation[]
createdTickets Ticket[] @relation("TicketUser")
reportedTickets Ticket[] @relation("TicketReportedUser")
createdNotes UserNote[] @relation("UserNoteUser")
reportedNotes UserNote[] @relation("UserNoteReportedUser")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Alliance {
id Int @id @default(autoincrement())
name String @unique
description String?
hqLatitude Float?
hqLongitude Float?
pixelsPainted Int @default(0)
members User[]
bannedUsers BannedUser[]
invites AllianceInvite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BannedUser {
id Int @id @default(autoincrement())
userId Int
allianceId Int
alliance Alliance @relation(fields: [allianceId], references: [id])
createdAt DateTime @default(now())
@@unique([userId, allianceId])
}
model AllianceInvite {
id String @id @default(uuid())
allianceId Int
alliance Alliance @relation(fields: [allianceId], references: [id])
createdAt DateTime @default(now())
}
model FavoriteLocation {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
name String @default("")
latitude Float
longitude Float
}
model Tile {
id Int @id @default(autoincrement())
x Int
y Int
imageData Bytes?
pixels Pixel[]
@@unique([x, y])
}
model Pixel {
id Int @id @default(autoincrement())
tileX Int
tileY Int
x Int
y Int
colorId Int
paintedBy Int
user User @relation(fields: [paintedBy], references: [id])
tile Tile @relation(fields: [tileX, tileY], references: [x, y])
paintedAt DateTime @default(now())
@@unique([tileX, tileY, x, y])
}
model Region {
id Int @id @default(autoincrement())
cityId Int @unique
name String
number Int
countryId Int
}
model ProfilePicture {
id Int @id @default(autoincrement())
userId Int
url String
}
model Session {
id String @id @default(uuid())
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
}
model Ticket {
id String @id @default(uuid())
userId Int
user User @relation("TicketUser", fields: [userId], references: [id])
reportedUserId Int
reportedUser User @relation("TicketReportedUser", fields: [reportedUserId], references: [id])
latitude Float
longitude Float
zoom Float
reason String
notes String
image String
resolved Boolean @default(false)
severe Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserNote {
id Int @id @default(autoincrement())
userId Int
user User @relation("UserNoteUser", fields: [userId], references: [id])
reportedUserId Int
reportedUser User @relation("UserNoteReportedUser", fields: [reportedUserId], references: [id])
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+2160
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export const JWT_SECRET = process.env["JWT_SECRET"];
if (!JWT_SECRET) {
throw new Error("JWT_SECRET is not defined");
}
+8
View File
@@ -0,0 +1,8 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export function addPrismaToRequest(req: any, _res: any, next: any) {
req.prisma = prisma;
return next?.();
}
+22
View File
@@ -0,0 +1,22 @@
export interface Region {
id: number;
cityId: number;
name: string;
number: number;
countryId: number;
flagId: number;
}
export function getRegionForCoordinates(tileX: number, tileY: number, x: number, y: number): Region {
const globalX = tileX * 1000 + x;
const globalY = tileY * 1000 + y;
// TODO: implement region lookup
return {
id: 114_594,
cityId: 4263,
name: "Cupertino",
number: 2,
countryId: 235,
flagId: 235
};
}
+85
View File
@@ -0,0 +1,85 @@
import { App } from "@tinyhttp/app";
import { cors } from "@tinyhttp/cors";
import { cookieParser } from "@tinyhttp/cookie-parser";
import dotenv from "dotenv";
import { inspect } from "util";
import admin from "./routes/admin.js";
import alliance from "./routes/alliance.js";
import auth from "./routes/auth.js";
import favoriteLocation from "./routes/favorite-location.js";
import leaderboard from "./routes/leaderboard.js";
import me from "./routes/me.js";
import moderator from "./routes/moderator.js";
import pixel from "./routes/pixel.js";
import store from "./routes/store.js";
import { addPrismaToRequest } from "./config/database.js";
import fs from "fs/promises";
dotenv.config();
const app = new App({
noMatchHandler: async (_req, res) => {
const html = await fs.readFile("./frontend/404.html", "utf-8");
return res.status(404)
.setHeader("Content-Type", "text/html")
.send(html);
}
});
app.use(cors());
app.use(cookieParser());
app.use((req, _res, next) => {
let body = "";
req.on("data", chunk => {
body += chunk.toString();
});
req.on("end", () => {
try {
req.body = body ? JSON.parse(body) : {};
} catch {
req.body = {};
}
return next?.();
});
});
// Logging
app.use((req, res, next) => {
const inspectOptions = { colors: true, compact: true, breakLength: Number.POSITIVE_INFINITY };
const startTime = Date.now();
const requestId = req.get("x-forwarded-for");
console.log(`[${requestId}] [${new Date()
.toISOString()}] ${req.method} ${req.url}`);
console.log(`[${requestId}] Headers:`, inspect(req.headers, inspectOptions));
if (req.body && Object.keys(req.body).length > 0) {
console.log(`[${requestId}] Body:`, inspect(req.body, inspectOptions));
}
const originalJson = res.json;
res.json = function (data) {
const duration = Date.now() - startTime;
console.log(`[${requestId}] Response JSON (${res.statusCode}) [${duration}ms]:`, inspect(data, inspectOptions));
return originalJson.call(this, data);
};
return next?.();
});
app.use(addPrismaToRequest);
admin(app);
alliance(app);
auth(app);
favoriteLocation(app);
leaderboard(app);
me(app);
moderator(app);
pixel(app);
store(app);
const PORT = Number(process.env["PORT"]) || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
+48
View File
@@ -0,0 +1,48 @@
import jwt from "jsonwebtoken";
import { JWT_SECRET } from "../config/auth.js";
import { prisma } from "../config/database.js";
export async function authMiddleware(req: any, res: any, next: any) {
try {
const token = req.cookies?.j;
if (!token) {
return res.status(401)
.json({ error: "Unauthorized", status: 401 });
}
const decoded = jwt.verify(token, JWT_SECRET) as any;
if (!decoded.userId || !decoded.sessionId) {
return res.status(401)
.json({ error: "Unauthorized", status: 401 });
}
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
return res.status(500)
.json({
error: "Internal Server Error. We'll look into it, please try again later.",
status: 500
});
}
const session = await prisma.session.findUnique({
where: { id: decoded.sessionId }
});
if (!session || session.userId !== decoded.userId || session.expiresAt < new Date()) {
return res.status(401)
.json({ error: "Unauthorized", status: 401 });
}
req.user = {
id: decoded.userId,
sessionId: decoded.sessionId
};
return next();
} catch {
return res.status(401)
.json({ error: "Unauthorized", status: 401 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { createErrorResponse, ERROR_MESSAGES, HTTP_STATUS } from "../utils/response.js";
export function handleServiceError(error: Error, res: any) {
console.error("Service error:", error);
switch (error.message) {
case "Bad Request":
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(ERROR_MESSAGES.BAD_REQUEST, HTTP_STATUS.BAD_REQUEST));
case "User not found":
return res.status(HTTP_STATUS.NOT_FOUND)
.json(createErrorResponse(ERROR_MESSAGES.USER_NOT_FOUND, HTTP_STATUS.NOT_FOUND));
case "No Alliance":
return res.status(HTTP_STATUS.NOT_FOUND)
.json(createErrorResponse(ERROR_MESSAGES.NO_ALLIANCE, HTTP_STATUS.NOT_FOUND));
case "Forbidden":
return res.status(HTTP_STATUS.FORBIDDEN)
.json(createErrorResponse(ERROR_MESSAGES.FORBIDDEN, HTTP_STATUS.FORBIDDEN));
case "refresh":
return res.status(HTTP_STATUS.UNAUTHORIZED)
.json(createErrorResponse(ERROR_MESSAGES.REFRESH_TOKEN, HTTP_STATUS.UNAUTHORIZED));
case "banned":
return res.status(HTTP_STATUS.UNAVAILABLE_FOR_LEGAL_REASONS)
.json({ err: "other", suspension: "ban" });
case "attempted to paint more pixels than there was charges.":
return res.status(HTTP_STATUS.FORBIDDEN)
.json(createErrorResponse(error.message, HTTP_STATUS.FORBIDDEN));
case "attempted to paint with a colour that was not purchased.":
return res.status(HTTP_STATUS.FORBIDDEN)
.json(createErrorResponse(error.message, HTTP_STATUS.FORBIDDEN));
case "The name has more than 16 characters":
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(error.message, HTTP_STATUS.BAD_REQUEST));
case "Alliance name is required":
case "Alliance name taken":
case "Already in alliance":
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(error.message, HTTP_STATUS.BAD_REQUEST));
case "Invalid invite":
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(ERROR_MESSAGES.INVALID_INVITE, HTTP_STATUS.BAD_REQUEST));
case "Not Found":
return res.status(HTTP_STATUS.NOT_FOUND)
.json(createErrorResponse(ERROR_MESSAGES.NOT_FOUND, HTTP_STATUS.NOT_FOUND));
case "Already Reported":
return res.status(HTTP_STATUS.ALREADY_REPORTED)
.json(createErrorResponse(ERROR_MESSAGES.ALREADY_REPORTED, HTTP_STATUS.ALREADY_REPORTED));
default:
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
.json(createErrorResponse(ERROR_MESSAGES.INTERNAL_SERVER_ERROR, HTTP_STATUS.INTERNAL_SERVER_ERROR));
}
}
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - openplace</title>
<style>
body {
font-family: sans-serif;
max-width: 400px;
margin: 100px auto;
padding: 20px;
background-color: #f5f5f5;
}
.login-form {
background: white;
padding: 30px;
border-radius: 10%;
box-shadow: 0 20px 50px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 12px;
font-size: 16px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 12px;
background-color: #0069ff;
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.error {
color: red;
margin-top: 10px;
text-align: center;
}
</style>
</head>
<body>
<div class="login-form">
<h1>Welcome back!</h1>
<form id="loginForm">
<div class="form-group">
<input type="text" id="username" name="username" placeholder="Username" required>
</div>
<div class="form-group">
<input type="password" id="password" name="password" placeholder="Password" required>
</div>
<button type="submit">Login</button>
<div id="error" class="error"></div>
</form>
</div>
<script>
document.getElementById("loginForm").addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const errorDiv = document.getElementById("error");
try {
const response = await fetch("/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
window.location.href = "/";
} else {
errorDiv.textContent = data.error || "Login failed";
}
} catch (error) {
errorDiv.textContent = "Network error occurred";
}
});
</script>
</body>
</html>
+538
View File
@@ -0,0 +1,538 @@
import { App, NextFunction, Response } from "@tinyhttp/app";
import { prisma } from "../config/database.js";
import { authMiddleware } from "../middleware/auth.js";
import { AuthenticatedRequest, UserRole } from "../types/index.js";
import { Prisma, Ticket } from "@prisma/client";
const REPORT_REASONS = [
{ key: "doxxing", label: "Doxxing" },
{ key: "inappropriate_content", label: "Inappropriate Content" },
{ key: "hate_speech", label: "Hate Speech" },
{ key: "bot", label: "Bot" },
{ key: "other", label: "Other" },
{ key: "griefing", label: "Griefing" }
];
const adminMiddleware = async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id }
});
if (!user || user.role !== UserRole.Admin) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
return next?.();
} catch (error) {
console.error("Error fetching user:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
};
export default function (app: App) {
app.get("/admin/users", authMiddleware, adminMiddleware, async (req, res) => {
try {
const id = Number.parseInt(req.query["id"] as string ?? "") || 0;
if (Number.isNaN(id) || id <= 0) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
const alliance = user.allianceId
? await prisma.alliance.findFirst({
where: { id: user.allianceId }
})
: null;
return res.status(200)
.json({
id: user.id,
name: user.name,
droplets: user.droplets,
picture: user.picture,
role: user.role,
timeout_until: user.timeoutUntil,
// TODO
ban_reason: null,
reported_times: 0,
timeouts_count: 0,
same_ip_accounts: 0,
alliance_id: user.allianceId,
alliance_name: alliance?.name,
pixels_painted: user.pixelsPainted,
phone_validated: false,
discord: user.discord
});
} catch (error) {
console.error("Error fetching users:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/users/notes", authMiddleware, adminMiddleware, async (req, res) => {
try {
const id = Number.parseInt(req.query["userId"] as string ?? "") || 0;
if (Number.isNaN(id) || id <= 0) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
const notes = await prisma.userNote.findMany({
where: { reportedUserId: id },
include: { user: true },
orderBy: { createdAt: "desc" }
});
return res.status(200)
.json({
notes: notes.map(note => ({
id: note.id,
admin: {
id: note.user.id,
name: note.user.name
},
content: note.content,
createdAt: note.createdAt
}))
});
} catch (error) {
console.error("Error fetching notes:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.post("/admin/users/notes", authMiddleware, adminMiddleware, async (req: AuthenticatedRequest, res) => {
try {
const id = Number.parseInt(req.body["userId"] as string ?? "") || 0;
const note = req.body["note"];
if (Number.isNaN(id) || id <= 0 || typeof note !== "string") {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
await prisma.userNote.create({
data: {
userId: req.user!.id,
reportedUserId: id,
content: note
}
});
return res.status(200)
.json({});
} catch (error) {
console.error("Error fetching notes:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/users/tickets", authMiddleware, adminMiddleware, async (req, res, next) => {
try {
const id = Number.parseInt(req.query["id"] as string ?? "") || 0;
if (Number.isNaN(id) || id <= 0) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
// TODO
return res.status(200)
.json({});
} catch (error) {
console.error("Error fetching tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/users/purchases", authMiddleware, adminMiddleware, async (req, res) => {
try {
const id = Number.parseInt(req.query["userId"] as string ?? "") || 0;
if (Number.isNaN(id) || id <= 0) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
// TODO
return res.status(200)
.json({});
} catch (error) {
console.error("Error fetching purchases:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.post("/admin/users/set-user-droplets", authMiddleware, adminMiddleware, async (req, res, next) => {
try {
const userId = Number.parseInt(req.body["userId"] as string ?? "") || 0;
const droplets = Number.parseInt(req.body["droplets"] as string ?? "") || 0;
if (Number.isNaN(userId) || userId <= 0 || Number.isNaN(droplets)) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
const newDroplets = user.droplets + droplets;
await prisma.user.update({
where: { id: userId },
data: { droplets: newDroplets }
});
return res.status(200)
.json({ success: true });
} catch (error) {
console.error("Error setting user droplets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get(["/admin/tickets", "/admin/closed-tickets"], authMiddleware, adminMiddleware, async (req: any, res: any) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user || user.role === UserRole.User) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
const resolved = req.path === "/admin/closed-tickets";
const tickets = await prisma.ticket.findMany({
where: { resolved },
orderBy: { createdAt: "desc" }
});
// Get all reported users
const reportedUserIds = tickets.map(ticket => ticket.reportedUserId);
const reportedUsers = await prisma.user.findMany({
where: { id: { in: reportedUserIds } },
select: {
id: true,
name: true,
discord: true,
country: true,
banned: true
}
});
const userMap = new Map(reportedUsers.map(user => [user.id, user]));
// Group tickets by reported user
const ticketsByUser = new Map<number, Ticket[]>();
for (const ticket of tickets) {
const userId = ticket.reportedUserId;
if (!ticketsByUser.has(userId)) {
ticketsByUser.set(userId, []);
}
ticketsByUser.get(userId)!.push(ticket);
}
const formattedTickets = [...ticketsByUser.entries()].map(([userId, userTickets]) => {
const reportedUser = userMap.get(userId);
return {
id: userId,
reportedUser: reportedUser
? {
id: reportedUser.id,
name: reportedUser.name,
discord: reportedUser.discord || "",
country: reportedUser.country,
banned: reportedUser.banned
}
: null,
createdAt: userTickets[0]?.createdAt,
reports: userTickets.map(ticket => ({
id: ticket.id,
latitude: ticket.latitude,
longitude: ticket.longitude,
zoom: ticket.zoom,
reason: ticket.reason,
notes: ticket.notes,
image: ticket.image,
createdAt: ticket.createdAt
}))
};
});
return res.status(200)
.json({ tickets: formattedTickets, status: 200 });
} catch (error) {
console.error("Error fetching moderator tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/open-tickets-count", authMiddleware, adminMiddleware, async (req: any, res: any) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user || user.role === UserRole.User) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
const count = await prisma.ticket.count({
where: { resolved: false }
});
return res.status(200)
.json({ tickets: count });
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.post("/admin/severe-open-tickets-count", authMiddleware, adminMiddleware, async (req: any, res: any) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user || user.role === UserRole.User) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
const count = await prisma.ticket.count({
where: {
severe: true,
resolved: false
}
});
return res.status(200)
.json({ tickets: count });
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.post("/admin/assign-new-tickets", authMiddleware, adminMiddleware, async (req: any, res: any) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user || user.role === UserRole.User) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
// TODO
res.json({
newTicketsIds: []
});
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/count-all-tickets", authMiddleware, adminMiddleware, async (_req: any, res: any) => {
try {
const results = await prisma.ticket.groupBy({
by: ["reason"],
where: {
resolved: false
},
_count: {
reason: true
}
});
const reasons = new Map<string, number>(REPORT_REASONS.map(item => [item.key, 0]));
for (const item of results) {
reasons.set(item.reason, item._count.reason);
}
const totalCount = [...reasons.values()].reduce((a, b) => a + b, 0);
return res.status(200)
.json({
...Object.fromEntries(reasons),
total_open_tickets: totalCount
});
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/count-all-reports", authMiddleware, adminMiddleware, async (_req: any, res: any) => {
try {
// TODO: Might need a separate table?
const results = await prisma.ticket.groupBy({
by: ["reason"],
where: {
resolved: false
},
_count: {
reason: true
}
});
const reasons = new Map<string, number>(REPORT_REASONS.map(item => [item.key, 0]));
for (const item of results) {
reasons.set(item.reason, item._count.reason);
}
const totalCount = [...reasons.values()].reduce((a, b) => a + b, 0);
return res.status(200)
.json({
...Object.fromEntries(reasons),
total_open_reports: totalCount
});
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get(["/admin/alliances/:id", "/admin/alliances/:id/full"], authMiddleware, adminMiddleware, async (req: any, res: any) => {
try {
const id = Number.parseInt(req.params["id"] as string ?? "");
if (Number.isNaN(id) || id <= 0) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const isFull = req.path.endsWith("/full");
const alliance = await prisma.alliance.findUnique({
where: { id },
select: isFull
? {
id: true,
name: true,
description: true,
hqLatitude: true,
hqLongitude: true,
pixelsPainted: true,
members: true,
bannedUsers: true,
createdAt: true,
updatedAt: true
}
: {
id: true,
name: true,
pixelsPainted: true
}
});
if (!alliance) {
return res.status(404)
.json({ error: "Alliance not found", status: 404 });
}
// TODO: Owner field
const owner = alliance.members?.[0];
const result = {
...alliance,
membersCount: alliance.members?.length || 0,
ownerId: owner?.id,
ownerName: owner?.name
};
return res.status(200)
.json(result);
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/admin/alliances/search", authMiddleware, adminMiddleware, async (req: any, res: any) => {
try {
const query = req.query["q"] as string ?? "";
const queryId = Number.parseInt(query) || 0;
let where: Prisma.AllianceWhereInput = {
name: {
contains: query
}
};
if (!Number.isNaN(queryId) && queryId !== 0) {
where = {
id: queryId
};
}
const results = await prisma.alliance.findMany({
where,
take: 20,
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
pixelsPainted: true
}
});
return res.status(200)
.json({ results });
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
}
+144
View File
@@ -0,0 +1,144 @@
import { App } from "@tinyhttp/app";
import { authMiddleware } from "../middleware/auth.js";
import { handleServiceError } from "../middleware/errorHandler.js";
import { AllianceService } from "../services/alliance.js";
import { validatePaginationPage } from "../validators/common.js";
import { createErrorResponse, HTTP_STATUS } from "../utils/response.js";
import { prisma } from "../config/database.js";
const allianceService = new AllianceService(prisma);
export default function (app: App) {
app.get("/alliance", authMiddleware, async (req: any, res: any) => {
try {
const result = await allianceService.getUserAlliance(req.user!.id);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/alliance", authMiddleware, async (req: any, res: any) => {
try {
const { name } = req.body;
const result = await allianceService.createAlliance(req.user!.id, { name });
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/alliance/update-description", authMiddleware, async (req: any, res: any) => {
try {
const { description } = req.body;
const result = await allianceService.updateDescription(req.user!.id, { description });
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.get("/alliance/invites", authMiddleware, async (req: any, res: any) => {
try {
const result = await allianceService.getInvites(req.user!.id);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.get("/alliance/join/:invite", authMiddleware, async (req: any, res: any) => {
try {
const { invite } = req.params;
const result = await allianceService.joinAlliance(req.user!.id, invite);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/alliance/update-headquarters", authMiddleware, async (req: any, res: any) => {
try {
const { latitude, longitude } = req.body;
const result = await allianceService.updateHeadquarters(req.user!.id, { latitude, longitude });
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.get("/alliance/members/:page", authMiddleware, async (req: any, res: any) => {
try {
const page = Number.parseInt(req.params.page) || 0;
if (!validatePaginationPage(page)) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
}
const result = await allianceService.getMembers(req.user!.id, page);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.get("/alliance/members/banned/:page", authMiddleware, async (req: any, res: any) => {
try {
const page = Number.parseInt(req.params.page) || 0;
if (!validatePaginationPage(page)) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
}
const result = await allianceService.getBannedMembers(req.user!.id, page);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/alliance/give-admin", authMiddleware, async (req: any, res: any) => {
try {
const { promotedUserId } = req.body;
await allianceService.promoteUser(req.user!.id, promotedUserId);
return res.status(HTTP_STATUS.OK)
.send();
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/alliance/ban", authMiddleware, async (req: any, res: any) => {
try {
const { bannedUserId } = req.body;
const result = await allianceService.banUser(req.user!.id, bannedUserId);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/alliance/unban", authMiddleware, async (req: any, res: any) => {
try {
const { unbannedUserId } = req.body;
const result = await allianceService.unbanUser(req.user!.id, unbannedUserId);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.get("/alliance/leaderboard/:mode", authMiddleware, async (req: any, res: any) => {
try {
const { mode } = req.params;
console.log(`Fetching leaderboard for mode: ${mode}`);
const result = await allianceService.getLeaderboard(req.user!.id, mode);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
}
+104
View File
@@ -0,0 +1,104 @@
import { App } from "@tinyhttp/app";
import bcrypt from "bcryptjs";
import { JWT_SECRET } from "../config/auth.js";
import { prisma } from "../config/database.js";
import { authMiddleware } from "../middleware/auth.js";
import jwt from "jsonwebtoken";
import fs from "fs/promises";
export default function (app: App) {
app.get("/login", async (_req, res) => {
const loginHtml = await fs.readFile("./src/public/login.html", "utf8");
res.setHeader("Content-Type", "text/html");
return res.send(loginHtml);
});
app.post("/login", async (req: any, res: any) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400)
.json({ error: "Username and password required" });
}
let user = await prisma.user.findFirst({
where: { name: username }
});
if (user) {
const passwordValid = await bcrypt.compare(password, user.passwordHash ?? "");
if (!passwordValid) {
return res.status(401)
.json({ error: "Invalid username or password" });
}
} else {
const passwordHash = await bcrypt.hash(password, 10);
user = await prisma.user.create({
data: {
name: username,
passwordHash,
country: "US", // TODO
droplets: 1000,
currentCharges: 20,
maxCharges: 20,
pixelsPainted: 0,
level: 1,
extraColorsBitmap: 0,
equippedFlag: 0,
chargesLastUpdatedAt: new Date()
}
});
}
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
}
});
const token = jwt.sign(
{
userId: user.id,
sessionId: session.id,
iss: "openplace",
exp: Math.floor(session.expiresAt.getTime() / 1000),
iat: Math.floor(Date.now() / 1000)
},
JWT_SECRET
);
res.setHeader("Set-Cookie", [
`j=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 60 * 60}; SameSite=Lax`
]);
return res.json({ success: true });
} catch (error) {
console.error("Login error:", error);
return res.status(500)
.json({ error: "Internal Server Error" });
}
});
app.post("/auth/logout", authMiddleware, async (req: any, res: any) => {
try {
if (req.user?.sessionId) {
await prisma.session.delete({
where: { id: req.user.sessionId }
})
}
res.setHeader("Set-Cookie", [
`j=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`
]);
return res.json({ success: true });
} catch (error) {
console.error("Logout error:", error);
return res.status(500)
.json({ error: "Internal Server Error" });
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import { App } from "@tinyhttp/app";
import { prisma } from "../config/database.js";
import { authMiddleware } from "../middleware/auth.js";
export default function (app: App) {
app.post("/favorite-location", authMiddleware, async (req: any, res: any) => {
try {
const { latitude, longitude, name } = req.body;
if (typeof latitude !== "number" || typeof longitude !== "number") {
return res.status(400)
.json({ error: "Invalid coordinates", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
include: { favoriteLocations: true }
});
if (!user) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
if (user.favoriteLocations.length >= user.maxFavoriteLocations) {
return res.status(403)
.json({ error: "Maximum favorite locations reached", status: 403 });
}
const favorite = await prisma.favoriteLocation.create({
data: {
userId: req.user!.id,
latitude,
longitude,
name: name || ""
}
});
return res.json({
id: favorite.id,
success: true
});
} catch (error) {
console.error("Error adding favorite location:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.delete("/favorite-location/delete", authMiddleware, async (req: any, res: any) => {
try {
const locationId = Number.parseInt(req.body.id);
if (Number.isNaN(locationId)) {
return res.status(400)
.json({ error: "Invalid location ID", status: 400 });
}
const deleted = await prisma.favoriteLocation.deleteMany({
where: {
id: locationId,
userId: req.user!.id
}
});
if (deleted.count === 0) {
return res.status(404)
.json({ error: "Favorite location not found", status: 404 });
}
return res.json({ success: true });
} catch (error) {
console.error("Error deleting favorite location:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
}
+357
View File
@@ -0,0 +1,357 @@
import { App } from "@tinyhttp/app";
import { prisma } from "../config/database.js";
const validModes = new Set(["today", "week", "month", "all-time"]);
export default function (app: App) {
app.get("/leaderboard/region/:mode/:country", async (req, res) => {
try {
const { mode, country } = req.params;
if (!mode || !validModes.has(mode)) {
return res.status(400)
.json({ error: "Invalid mode", status: 400 });
}
const regions = await prisma.region.findMany({
where: {
countryId: Number.parseInt(country || "0")
},
orderBy: { name: "asc" }
});
// TODO
const regionsWithStats = await Promise.all(
regions.map(async (region) => {
return {
id: region.id,
name: region.name,
cityId: region.cityId,
number: region.number,
countryId: region.countryId,
pixelsPainted: 1234,
lastLatitude: 0,
lastLongitude: 0
};
})
);
regionsWithStats.sort((a, b) => b.pixelsPainted - a.pixelsPainted);
return res.json(regionsWithStats);
} catch (error) {
console.error("Error fetching region leaderboard:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/leaderboard/country/:mode", async (req, res) => {
try {
const { mode } = req.params;
if (!mode || !validModes.has(mode)) {
return res.status(400)
.json({ error: "Invalid mode", status: 400 });
}
// TODO: calculate country pixel data
const mockCountries = [
{ id: 235, pixelsPainted: 1_234 }
];
return res.json(mockCountries);
} catch (error) {
console.error("Error fetching country leaderboard:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/leaderboard/player/:mode", async (req, res) => {
try {
const { mode } = req.params;
if (!mode || !validModes.has(mode)) {
return res.status(400)
.json({ error: "Invalid mode", status: 400 });
}
let dateFilter = {};
const now = new Date();
switch (mode) {
case "today": {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
dateFilter = { paintedAt: { gte: startOfDay } };
break;
}
case "week": {
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - 7);
dateFilter = { paintedAt: { gte: startOfWeek } };
break;
}
case "month": {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
dateFilter = { paintedAt: { gte: startOfMonth } };
break;
}
}
if (mode === "all-time") {
const players = await prisma.user.findMany({
orderBy: { pixelsPainted: "desc" },
take: 50,
select: {
id: true,
name: true,
allianceId: true,
equippedFlag: true,
pixelsPainted: true,
picture: true,
discord: true,
alliance: {
select: { name: true }
}
}
});
const response = players.map(player => ({
id: player.id,
name: player.name,
allianceId: player.allianceId || 0,
allianceName: player.alliance?.name || "",
equippedFlag: player.equippedFlag,
pixelsPainted: player.pixelsPainted,
picture: player.picture || undefined,
discord: player.discord || ""
}));
return res.json(response);
} else {
// Count pixels by time period
const pixelCounts = await prisma.pixel.groupBy({
by: ["paintedBy"],
_count: { id: true },
where: dateFilter,
orderBy: { _count: { id: "desc" } },
take: 50
});
const userIds = pixelCounts.map(p => p.paintedBy);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
name: true,
allianceId: true,
equippedFlag: true,
picture: true,
discord: true,
alliance: {
select: { name: true }
}
}
});
const userMap = new Map(users.map(u => [u.id, u]));
const response = pixelCounts.map(pixel => {
const user = userMap.get(pixel.paintedBy);
return {
id: user?.id || pixel.paintedBy,
name: user?.name || "Unknown",
allianceId: user?.allianceId || 0,
allianceName: user?.alliance?.name || "",
equippedFlag: user?.equippedFlag || 0,
pixelsPainted: pixel._count.id,
picture: user?.picture || undefined,
discord: user?.discord || ""
};
});
return res.json(response);
}
} catch (error) {
console.error("Error fetching player leaderboard:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/leaderboard/alliance/:mode", async (req, res) => {
try {
const { mode } = req.params;
if (!mode || !validModes.has(mode)) {
return res.status(400)
.json({ error: "Invalid mode", status: 400 });
}
let dateFilter = {};
const now = new Date();
switch (mode) {
case "today": {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
dateFilter = { paintedAt: { gte: startOfDay } };
break;
}
case "week": {
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - 7);
dateFilter = { paintedAt: { gte: startOfWeek } };
break;
}
case "month": {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
dateFilter = { paintedAt: { gte: startOfMonth } };
break;
}
}
if (mode === "all-time") {
const alliances = await prisma.alliance.findMany({
orderBy: { pixelsPainted: "desc" },
take: 50,
select: {
id: true,
name: true,
pixelsPainted: true
}
});
return res.json(alliances);
} else {
// Count pixels by time period and alliance
const pixelCounts = await prisma.pixel.groupBy({
by: ["paintedBy"],
_count: { id: true },
where: dateFilter
});
// Get user alliance memberships
const userIds = pixelCounts.map(p => p.paintedBy);
const users = await prisma.user.findMany({
where: {
id: { in: userIds },
allianceId: { not: null }
},
select: {
id: true,
allianceId: true
}
});
// Group pixels by alliance
const alliancePixelCounts = new Map<number, number>();
const userAllianceMap = new Map(users.map(u => [u.id, u.allianceId!]));
for (const pixel of pixelCounts) {
const allianceId = userAllianceMap.get(pixel.paintedBy);
if (allianceId) {
const currentCount = alliancePixelCounts.get(allianceId) || 0;
alliancePixelCounts.set(allianceId, currentCount + pixel._count.id);
}
}
// Get alliance details and sort by pixel count
const allianceIds = [...alliancePixelCounts.keys()];
const alliances = await prisma.alliance.findMany({
where: { id: { in: allianceIds } },
select: {
id: true,
name: true
}
});
const response = alliances.map(alliance => ({
id: alliance.id,
name: alliance.name,
pixelsPainted: alliancePixelCounts.get(alliance.id) || 0
}))
.sort((a, b) => b.pixelsPainted - a.pixelsPainted)
.slice(0, 50);
return res.json(response);
}
} catch (error) {
console.error("Error fetching alliance leaderboard:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/leaderboard/region/players/:city/:mode", async (req, res) => {
try {
// TODO: city not being used
const { city: _city, mode } = req.params;
if (!mode || !validModes.has(mode)) {
return res.status(400)
.json({ error: "Invalid mode", status: 400 });
}
const players = await prisma.user.findMany({
orderBy: { pixelsPainted: "desc" },
take: 50,
select: {
id: true,
name: true,
allianceId: true,
equippedFlag: true,
pixelsPainted: true,
picture: true,
discord: true,
alliance: {
select: { name: true }
}
}
});
const response = players.map(player => ({
id: player.id,
name: player.name,
allianceId: player.allianceId || 0,
allianceName: player.alliance?.name || "",
pixelsPainted: player.pixelsPainted,
equippedFlag: player.equippedFlag,
picture: player.picture || undefined,
discord: player.discord || ""
}));
return res.json(response);
} catch (error) {
console.error("Error fetching regional players leaderboard:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/leaderboard/region/alliances/:city/:mode", async (req, res) => {
try {
// TODO: city not being used
const { city: _city, mode } = req.params;
if (!mode || !validModes.has(mode)) {
return res.status(400)
.json({ error: "Invalid mode", status: 400 });
}
const alliances = await prisma.alliance.findMany({
orderBy: { pixelsPainted: "desc" },
take: 50,
select: {
id: true,
name: true,
pixelsPainted: true
}
});
return res.json(alliances);
} catch (error) {
console.error("Error fetching regional alliances leaderboard:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
}
+46
View File
@@ -0,0 +1,46 @@
import { App } from "@tinyhttp/app";
import { authMiddleware } from "../middleware/auth.js";
import { handleServiceError } from "../middleware/errorHandler.js";
import { UserService } from "../services/user.js";
import { validateUpdateUser } from "../validators/user.js";
import { createErrorResponse, HTTP_STATUS } from "../utils/response.js";
import { prisma } from "../config/database.js";
const userService = new UserService(prisma);
export default function (app: App) {
app.get("/me", authMiddleware, async (req: any, res: any) => {
try {
const result = await userService.getUserProfile(req.user!.id);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.post("/me/update", authMiddleware, async (req: any, res: any) => {
try {
const { name, showLastPixel, discord } = req.body;
const validationError = validateUpdateUser({ name, showLastPixel, discord });
if (validationError) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
}
const result = await userService.updateUser(req.user!.id, { name, showLastPixel, discord });
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
app.get("/me/profile-pictures", authMiddleware, async (req: any, res: any) => {
try {
const result = await userService.getProfilePictures(req.user!.id);
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
}
+262
View File
@@ -0,0 +1,262 @@
import { App, NextFunction, Response } from "@tinyhttp/app";
import { prisma } from "../config/database.js";
import { authMiddleware } from "../middleware/auth.js";
import { AuthenticatedRequest, UserRole } from "../types/index.js";
import { Ticket, User } from "@prisma/client";
const moderatorMiddleware = async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id }
});
if (!user || user.role === UserRole.User) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
return next?.();
} catch (error) {
console.error("Error fetching user:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
};
export default function (app: App) {
app.get("/moderator/tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
try {
const tickets = await prisma.ticket.findMany({
where: {
resolved: false
},
select: {
user: {
select: {
id: true,
name: true,
discord: true,
country: true,
banned: true
}
},
reportedUser: {
select: {
id: true,
name: true,
discord: true,
country: true,
banned: true
}
}
}
});
// Get all reported users
const reportedUserIds = tickets.map(ticket => ticket.reportedUserId);
const reportedUsers = await prisma.user.findMany({
where: { id: { in: reportedUserIds } },
select: {}
});
const userMap = new Map(reportedUsers.map(user => [user.id, user]));
// Group tickets by reported user
const ticketsByUser = new Map<number, Ticket[]>();
const authors = new Map<number, User | null>();
await Promise.all(tickets.map(async ticket => {
const userId = ticket.userId;
if (!authors.has(userId)) {
authors.set(userId, await prisma.user.findFirst({
where: { id: userId }
}));
}
const reportedUserID = ticket.reportedUserId;
if (!ticketsByUser.has(reportedUserID)) {
ticketsByUser.set(reportedUserID, []);
}
ticketsByUser.get(reportedUserID)!.push(ticket);
}));
const formattedTickets = [...ticketsByUser.entries()].map(([userId, userTickets]) => {
const author = authors.get(userTickets[0]?.userId || 0);
const reportedUser = userMap.get(userId);
return {
id: userId,
author: author
? {
id: author.id,
name: author.name,
discord: author.discord || "",
country: author.country,
banned: author.banned
}
: null,
reportedUser: reportedUser
? {
id: reportedUser.id,
name: reportedUser.name,
discord: reportedUser.discord || "",
country: reportedUser.country,
banned: reportedUser.banned
}
: null,
createdAt: userTickets[0]?.createdAt,
reports: userTickets.map(ticket => ({
id: ticket.id,
latitude: ticket.latitude,
longitude: ticket.longitude,
zoom: ticket.zoom,
reason: ticket.reason,
notes: ticket.notes,
image: ticket.image,
createdAt: ticket.createdAt
}))
};
});
return res.status(200)
.json({ tickets: formattedTickets, status: 200 });
} catch (error) {
console.error("Error fetching moderator tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/moderator/users/tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
try {
const userId = Number.parseInt(req.query.userId as string ?? "") || 0;
if (Number.isNaN(userId) || userId <= 0) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const reportedUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
discord: true,
country: true,
banned: true
}
});
if (!reportedUser) {
return res.status(404)
.json({ error: "User not found", status: 404 });
}
const tickets = await prisma.ticket.findMany({
where: { reportedUserId: userId }
});
const authors = new Map<number, User | null>();
await Promise.all(tickets.map(async ticket => {
const userId = ticket.userId;
if (!authors.has(userId)) {
authors.set(userId, await prisma.user.findFirst({
where: { id: userId }
}));
}
}));
const formattedTickets = tickets.map(ticket => {
const author = authors.get(ticket.userId);
return {
id: userId,
author: author
? {
id: author.id,
name: author.name,
discord: author.discord || "",
country: author.country,
banned: author.banned
}
: null,
reportedUser: reportedUser
? {
id: reportedUser.id,
name: reportedUser.name,
discord: reportedUser.discord || "",
country: reportedUser.country,
banned: reportedUser.banned
}
: null,
createdAt: ticket.createdAt,
reports: [ticket].map(ticket => ({
id: ticket.id,
latitude: ticket.latitude,
longitude: ticket.longitude,
zoom: ticket.zoom,
reason: ticket.reason,
notes: ticket.notes,
image: ticket.image,
createdAt: ticket.createdAt
}))
};
});
return res.status(200)
.json({ tickets: formattedTickets, status: 200 });
} catch (error) {
console.error("Error fetching moderator tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/moderator/open-tickets-count", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
try {
const count = await prisma.ticket.count({
where: { resolved: false }
});
return res.status(200)
.json({ tickets: count });
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.post("/moderator/severe-open-tickets-count", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
try {
const count = await prisma.ticket.count({
where: {
severe: true,
resolved: false
}
});
return res.status(200)
.json({ tickets: count });
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.post("/moderator/assign-new-tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
try {
// TODO
res.json({
newTicketsIds: []
});
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
app.get("/moderator/count-my-tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
try {
// TODO
res.json(0);
} catch (error) {
console.error("Error assigning new tickets:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
}
+100
View File
@@ -0,0 +1,100 @@
import { App } from "@tinyhttp/app";
import { authMiddleware } from "../middleware/auth.js";
import { handleServiceError } from "../middleware/errorHandler.js";
import { PixelService } from "../services/pixel.js";
import { validateSeason, validateTileCoordinates } from "../validators/common.js";
import { validatePaintPixels, validatePixelInfo } from "../validators/pixel.js";
import { createErrorResponse, HTTP_STATUS } from "../utils/response.js";
import { prisma } from "../config/database.js";
const pixelService = new PixelService(prisma);
export default function (app: App) {
app.get("/:season/tile/random", async (req: any, res: any) => {
try {
const season = req.params["season"];
if (!validateSeason(season)) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
}
const result = await pixelService.getRandomTile();
return res.json(result);
} catch (error) {
console.error("Error getting random tile:", error);
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
.json(createErrorResponse("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR));
}
});
app.get("/:season/pixel/:tileX/:tileY", async (req: any, res: any) => {
try {
const season = req.params["season"];
const tileX = Number.parseInt(req.params["tileX"]);
const tileY = Number.parseInt(req.params["tileY"]);
const x = Number.parseInt(req.query["x"] as string);
const y = Number.parseInt(req.query["y"] as string);
const validationError = validatePixelInfo({ season, tileX, tileY, x, y });
if (validationError) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
}
const result = await pixelService.getPixelInfo(tileX, tileY, x, y);
return res.json(result);
} catch (error) {
console.error("Error getting pixel info:", error);
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
.json(createErrorResponse("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR));
}
});
app.get("/files/:season/tiles/:tileX/:tileY.png", async (req: any, res: any) => {
try {
const season = req.params["season"];
const tileX = Number.parseInt(req.params["tileX"]);
const tileY = Number.parseInt(req.params["tileY"]);
if (!validateSeason(season)) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
}
if (!validateTileCoordinates(tileX, tileY)) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
}
const imageBuffer = await pixelService.generateTileImage(tileX, tileY);
res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "public, max-age=300");
return res.send(imageBuffer);
} catch (error) {
console.error("Error generating tile image:", error);
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
.json(createErrorResponse("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR));
}
});
app.post("/:season/pixel/:tileX/:tileY", authMiddleware, async (req: any, res: any) => {
try {
const season = req.params["season"];
const tileX = Number.parseInt(req.params["tileX"]);
const tileY = Number.parseInt(req.params["tileY"]);
const { colors, coords } = req.body;
const validationError = validatePaintPixels({ season, tileX, tileY, colors, coords });
if (validationError) {
return res.status(HTTP_STATUS.BAD_REQUEST)
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
}
const result = await pixelService.paintPixels(req.user!.id, { tileX, tileY, colors, coords });
return res.json(result);
} catch (error) {
return handleServiceError(error as Error, res);
}
});
}
+130
View File
@@ -0,0 +1,130 @@
import { App } from "@tinyhttp/app";
import { WplaceBitMap } from "../utils/bitmap.js";
import { authMiddleware } from "../middleware/auth.js";
import { prisma } from "../config/database.js";
const STORE_ITEMS = {
70: { name: "+5 Max. Charges", price: 500, type: "charges" },
80: { name: "+30 Paint Charges", price: 500, type: "paint" },
100: { name: "Unlock Paid Colors", price: 2000, type: "color" },
110: { name: "Unlock Flag", price: 20_000, type: "flag" }
};
export default function (app: App) {
app.post("/purchase", authMiddleware, async (req: any, res: any) => {
try {
const { product } = req.body;
if (!product || !product.id) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const item = STORE_ITEMS[product.id as keyof typeof STORE_ITEMS];
if (!item) {
return res.status(400)
.json({ error: "Invalid item", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id }
});
if (!user) {
return res.status(401)
.json({ error: "Unauthorized", status: 401 });
}
const totalCost = item.price * (product.amount || 1);
if (user.droplets < totalCost) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
const updateData: any = {
droplets: user.droplets - totalCost
};
switch (item.type) {
case "charges":
updateData.maxCharges = user.maxCharges + (5 * (product.amount || 1));
break;
case "paint":
updateData.currentCharges = Math.min(
user.maxCharges,
user.currentCharges + (30 * (product.amount || 1))
);
break;
case "color":
if (product.variant && product.variant >= 32 && product.variant <= 63) {
const mask = 1 << (product.variant - 32);
updateData.extraColorsBitmap = user.extraColorsBitmap | mask;
}
break;
case "flag":
if (product.variant && product.variant >= 1 && product.variant <= 251) {
const flagsBitmap = user.flagsBitmap
? WplaceBitMap.fromBase64(Buffer.from(user.flagsBitmap)
.toString("base64"))
: new WplaceBitMap();
flagsBitmap.set(product.variant, true);
updateData.flagsBitmap = Buffer.from(flagsBitmap.toBase64(), "base64");
}
break;
}
await prisma.user.update({
where: { id: req.user!.id },
data: updateData
});
return res.json({ success: true });
} catch (error) {
console.error("Error purchasing item:", error);
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
});
app.post("/flag/equip/:id", authMiddleware, async (req: any, res: any) => {
try {
const flagId = Number.parseInt(req.params["id"]);
if (Number.isNaN(flagId) || flagId < 1 || flagId > 251) {
return res.status(400)
.json({ error: "Bad Request", status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id }
});
if (!user) {
return res.status(401)
.json({ error: "Unauthorized", status: 401 });
}
const flagsBitmap = user.flagsBitmap
? WplaceBitMap.fromBase64(Buffer.from(user.flagsBitmap)
.toString("base64"))
: new WplaceBitMap();
if (!flagsBitmap.get(flagId)) {
return res.status(403)
.json({ error: "Forbidden", status: 403 });
}
await prisma.user.update({
where: { id: req.user!.id },
data: { equippedFlag: flagId }
});
return res.json({ success: true });
} catch (error) {
console.error("Error equipping flag:", error);
return res.status(500)
.json({ error: "Internal Server Error", status: 500 });
}
});
}
+373
View File
@@ -0,0 +1,373 @@
import { PrismaClient } from "@prisma/client";
export interface CreateAllianceInput {
name: string;
}
export interface UpdateAllianceDescriptionInput {
description: string;
}
export interface UpdateAllianceHQInput {
latitude: number;
longitude: number;
}
const PAGINATION_CONSTANTS = {
PAGE_SIZE: 50
} as const;
export class AllianceService {
constructor(private prisma: PrismaClient) {}
async getUserAlliance(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { alliance: true }
});
if (!user || !user.alliance) {
throw new Error("No Alliance");
}
const memberCount = await this.prisma.user.count({
where: { allianceId: user.alliance.id }
});
return {
id: user.alliance.id,
name: user.alliance.name,
description: user.alliance.description || "",
hq: user.alliance.hqLatitude && user.alliance.hqLongitude
? {
latitude: user.alliance.hqLatitude,
longitude: user.alliance.hqLongitude
}
: null,
members: memberCount,
pixelsPainted: user.alliance.pixelsPainted || 0,
role: user.allianceRole,
createdAt: user.alliance.createdAt.toISOString(),
updatedAt: user.alliance.updatedAt.toISOString()
};
}
async createAlliance(userId: number, input: CreateAllianceInput) {
const { name } = input;
if (!name || typeof name !== "string") {
throw new Error("Alliance name is required");
}
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (user?.allianceId) {
throw new Error("Already in alliance");
}
const existingAlliance = await this.prisma.alliance.findUnique({
where: { name }
});
if (existingAlliance) {
throw new Error("Alliance name taken");
}
const alliance = await this.prisma.alliance.create({
data: {
name,
description: "",
pixelsPainted: 0
}
});
await this.prisma.user.update({
where: { id: userId },
data: {
allianceId: alliance.id,
allianceRole: "admin"
}
});
return { id: alliance.id };
}
async updateDescription(userId: number, input: UpdateAllianceDescriptionInput) {
const { description } = input;
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
await this.prisma.alliance.update({
where: { id: user.allianceId },
data: { description }
});
return { success: true };
}
async getInvites(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
let invite = await this.prisma.allianceInvite.findFirst({
where: { allianceId: user.allianceId }
});
if (!invite) {
invite = await this.prisma.allianceInvite.create({
data: { allianceId: user.allianceId }
});
}
return [invite.id];
}
async joinAlliance(userId: number, inviteId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
throw new Error("User not found");
}
if (!inviteId) {
throw new Error("Invalid invite");
}
const inviteRecord = await this.prisma.allianceInvite.findUnique({
where: { id: inviteId }
});
if (!inviteRecord) {
throw new Error("Not Found");
}
if (user.allianceId === inviteRecord.allianceId) {
return { success: "true" };
}
if (user.allianceId) {
throw new Error("Already Reported");
}
const bannedUser = await this.prisma.bannedUser.findUnique({
where: {
userId_allianceId: {
userId,
allianceId: inviteRecord.allianceId
}
}
});
if (bannedUser) {
throw new Error("Forbidden");
}
await this.prisma.user.update({
where: { id: userId },
data: {
allianceId: inviteRecord.allianceId,
allianceRole: "member"
}
});
return { success: "true" };
}
async updateHeadquarters(userId: number, input: UpdateAllianceHQInput) {
const { latitude, longitude } = input;
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
await this.prisma.alliance.update({
where: { id: user.allianceId },
data: {
hqLatitude: latitude,
hqLongitude: longitude
}
});
return { success: true };
}
async getMembers(userId: number, page: number) {
const pageSize = PAGINATION_CONSTANTS.PAGE_SIZE;
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
const members = await this.prisma.user.findMany({
where: { allianceId: user.allianceId },
skip: page * pageSize,
take: pageSize + 1,
select: {
id: true,
name: true,
allianceRole: true
}
});
const hasNext = members.length > pageSize;
const data = hasNext ? members.slice(0, -1) : members;
return {
data: data.map(member => ({
id: member.id,
name: member.name,
role: member.allianceRole
})),
hasNext
};
}
async getBannedMembers(userId: number, page: number) {
const pageSize = PAGINATION_CONSTANTS.PAGE_SIZE;
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
const bannedUsers = await this.prisma.bannedUser.findMany({
where: { allianceId: user.allianceId },
skip: page * pageSize,
take: pageSize + 1
});
const hasNext = bannedUsers.length > pageSize;
const data = hasNext ? bannedUsers.slice(0, -1) : bannedUsers;
return {
data: data.map(banned => ({
id: banned.userId,
name: `User ${banned.userId}`
})),
hasNext
};
}
async promoteUser(userId: number, promotedUserId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
await this.prisma.user.update({
where: {
id: promotedUserId,
allianceId: user.allianceId
},
data: { allianceRole: "admin" }
});
}
async banUser(userId: number, bannedUserId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
await this.prisma.user.update({
where: { id: bannedUserId },
data: { allianceId: null, allianceRole: "member" }
});
await this.prisma.bannedUser.create({
data: {
userId: bannedUserId,
allianceId: user.allianceId
}
});
return { success: true };
}
async unbanUser(userId: number, unbannedUserId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId || user.allianceRole !== "admin") {
throw new Error("Forbidden");
}
await this.prisma.bannedUser.delete({
where: {
userId_allianceId: {
userId: unbannedUserId,
allianceId: user.allianceId
}
}
});
return { success: true };
}
async getLeaderboard(userId: number, _mode: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.allianceId) {
throw new Error("Forbidden");
}
const members = await this.prisma.user.findMany({
where: { allianceId: user.allianceId },
orderBy: { pixelsPainted: "desc" },
take: 50,
select: {
id: true,
name: true,
equippedFlag: true,
pixelsPainted: true,
showLastPixel: true
}
});
return members.map(member => ({
userId: member.id,
name: member.name,
equippedFlag: member.equippedFlag,
pixelsPainted: member.pixelsPainted,
...(member.showLastPixel && {
lastLatitude: 22.527_739_206_672_393,
lastLongitude: 114.027_626_953_124_97
})
}));
}
}
+306
View File
@@ -0,0 +1,306 @@
import { PrismaClient } from "@prisma/client";
import { createCanvas } from "@napi-rs/canvas";
import { checkColorUnlocked, COLOR_PALETTE } from "../utils/colors.js";
import { calculateChargeRecharge } from "../utils/charges.js";
import { getRegionForCoordinates } from "../config/regions.js";
export interface PaintPixelsInput {
tileX: number;
tileY: number;
colors: number[];
coords: number[];
}
export interface PaintPixelsResult {
painted: number;
}
export interface RandomTileResult {
pixel: { x: number; y: number };
tile: { x: number; y: number };
}
export interface PixelInfoResult {
paintedBy: {
id: number;
name: string;
allianceId: number;
allianceName: string;
equippedFlag: number;
};
region: any;
}
function calculateLevel(pixelsPainted: number): number {
return Math.floor(Math.sqrt(pixelsPainted / 100)) + 1;
}
export class PixelService {
constructor(private prisma: PrismaClient) {}
async getRandomTile(): Promise<RandomTileResult> {
const recentThreshold = new Date(Date.now() - 24 * 60 * 60 * 1000);
let recentPixelCount = await this.prisma.pixel.count({
where: {
paintedAt: {
gte: recentThreshold
}
}
});
let firstRecentPixel = await this.prisma.pixel.findFirst({
select: {
id: true
},
where: {
paintedAt: {
gte: recentThreshold
}
},
orderBy: { paintedAt: "desc" }
});
if (recentPixelCount === 0 || !firstRecentPixel) {
recentPixelCount = await this.prisma.pixel.count();
firstRecentPixel = await this.prisma.pixel.findFirst({
select: {
id: true
},
orderBy: { paintedAt: "desc" }
});
}
const id = (firstRecentPixel?.id || 1) + Math.floor(Math.random() * recentPixelCount);
const randomPixel = await this.prisma.pixel.findFirst({
select: {
x: true,
y: true,
tileX: true,
tileY: true
},
where: {
id: { gte: id }
},
orderBy: { id: "asc" }
});
if (!randomPixel) {
return {
pixel: { x: 500, y: 500 },
tile: { x: 1024, y: 1024 }
};
}
return {
pixel: { x: randomPixel.x, y: randomPixel.y },
tile: { x: randomPixel.tileX, y: randomPixel.tileY }
};
}
async getPixelInfo(tileX: number, tileY: number, x: number, y: number): Promise<PixelInfoResult> {
let paintedBy = {
id: 0,
name: "",
allianceId: 0,
allianceName: "",
equippedFlag: 0
};
const pixel = await this.prisma.pixel.findUnique({
where: {
tileX_tileY_x_y: { tileX, tileY, x, y }
},
include: {
user: {
include: { alliance: true }
}
}
});
if (pixel) {
paintedBy = {
id: pixel.user.id,
name: pixel.user.name,
allianceId: pixel.user.allianceId || 0,
allianceName: pixel.user.alliance?.name || "",
equippedFlag: pixel.user.equippedFlag
};
}
const region = getRegionForCoordinates(tileX, tileY, x, y);
return { paintedBy, region };
}
async generateTileImage(tileX: number, tileY: number): Promise<Buffer> {
const canvas = createCanvas(1000, 1000);
const ctx = canvas.getContext("2d");
const pixels = await this.prisma.pixel.findMany({
where: { tileX, tileY }
});
for (const pixel of pixels) {
const color = COLOR_PALETTE[pixel.colorId];
if (color && pixel.colorId !== 0) {
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");
}
async paintPixels(userId: number, input: PaintPixelsInput): Promise<PaintPixelsResult> {
const { tileX, tileY, colors, coords } = input;
if (!colors || !coords || !Array.isArray(colors) || !Array.isArray(coords)) {
throw new Error("Bad Request");
}
if (colors.length * 2 !== coords.length) {
throw new Error("Bad Request");
}
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
throw new Error("refresh");
}
if (user.banned || user.timeoutUntil > new Date()) {
throw new Error("banned");
}
const currentCharges = calculateChargeRecharge(
user.currentCharges,
user.maxCharges,
user.chargesLastUpdatedAt || new Date(),
user.chargesCooldownMs
);
if (currentCharges < colors.length) {
throw new Error("attempted to paint more pixels than there was charges.");
}
user.currentCharges = currentCharges;
for (const colorId of colors) {
if (!checkColorUnlocked(colorId, user.extraColorsBitmap)) {
throw new Error("attempted to paint with a colour that was not purchased.");
}
}
const pairedCoords = [];
for (let i = 0; i < coords.length; i += 2) {
pairedCoords.push({ x: coords[i], y: coords[i + 1] });
}
const regionCache = new Map();
const validPixels = [];
for (const [i, colorId] of colors.entries()) {
const coord = pairedCoords[i];
if (!coord) continue;
const { x, y } = coord;
if (x === undefined || y === undefined || x < 0 || x >= 1000 || y < 0 || y >= 1000) {
continue;
}
const coordKey = `${tileX},${tileY},${x},${y}`;
let region;
if (regionCache.has(coordKey)) {
region = regionCache.get(coordKey);
} else {
region = getRegionForCoordinates(tileX, tileY, x, y);
regionCache.set(coordKey, region);
}
validPixels.push({
x, y, colorId, region
});
}
const painted = validPixels.length;
if (painted === 0) {
return { painted: 0 };
}
const userEquippedFlag = user.equippedFlag;
let totalChargeCost = 0;
let discountedPixels = 0;
for (const pixel of validPixels) {
if (userEquippedFlag && userEquippedFlag === pixel.region.flagId) {
totalChargeCost += 0.9;
discountedPixels++;
} else {
totalChargeCost += 1;
}
}
if (discountedPixels > 0 && validPixels[0]) {
console.log(`Applied 10% flag discount to ${discountedPixels} pixels in ${validPixels[0].region.name}`);
}
await this.prisma.tile.upsert({
where: { x_y: { x: tileX, y: tileY } },
create: { x: tileX, y: tileY },
update: {}
});
const now = new Date();
if (validPixels.length > 0) {
const values = validPixels.map(pixel =>
`(${tileX}, ${tileY}, ${pixel.x}, ${pixel.y}, ${pixel.colorId}, ${userId}, '${now.toISOString()
.slice(0, 19)
.replace("T", " ")}')`
)
.join(", ");
const bulkQuery = `
INSERT INTO Pixel (tileX, tileY, x, y, colorId, paintedBy, paintedAt)
VALUES ${values}
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);
const newPixelsPainted = user.pixelsPainted + painted;
const newLevel = calculateLevel(newPixelsPainted);
await this.prisma.user.update({
where: { id: userId },
data: {
currentCharges: newCharges,
pixelsPainted: newPixelsPainted,
level: newLevel,
chargesLastUpdatedAt: new Date()
}
});
if (user.allianceId) {
await this.prisma.alliance.update({
where: { id: user.allianceId },
data: {
pixelsPainted: { increment: painted }
}
});
}
return { painted };
}
}
+113
View File
@@ -0,0 +1,113 @@
import { PrismaClient } from "@prisma/client";
import { calculateChargeRecharge } from "../utils/charges.js";
export interface UpdateUserInput {
name?: string;
showLastPixel?: boolean;
discord?: string;
}
export class UserService {
constructor(private prisma: PrismaClient) {}
async getUserProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
alliance: true,
favoriteLocations: true
}
});
if (!user) {
throw new Error("User not found");
}
const updatedCharges = calculateChargeRecharge(
user.currentCharges,
user.maxCharges,
user.chargesLastUpdatedAt,
user.chargesCooldownMs
);
if (updatedCharges !== user.currentCharges) {
await this.prisma.user.update({
where: { id: userId },
data: {
currentCharges: updatedCharges,
chargesLastUpdatedAt: new Date()
}
});
user.currentCharges = updatedCharges;
}
const flagsBitmap = user.flagsBitmap
? Buffer.from(user.flagsBitmap)
.toString("base64")
: "AA==";
return {
id: user.id,
name: user.name,
discord: user.discord || "",
country: user.country,
banned: user.banned,
charges: {
cooldownMs: user.chargesCooldownMs,
count: user.currentCharges,
max: user.maxCharges
},
droplets: user.droplets,
equippedFlag: user.equippedFlag,
extraColorsBitmap: user.extraColorsBitmap,
favoriteLocations: user.favoriteLocations.map(loc => ({
id: loc.id,
name: loc.name,
latitude: loc.latitude,
longitude: loc.longitude
})),
flagsBitmap,
role: user.role,
isCustomer: user.isCustomer,
level: user.level,
maxFavoriteLocations: user.maxFavoriteLocations,
needsPhoneVerification: user.needsPhoneVerification,
picture: user.picture || "",
pixelsPainted: user.pixelsPainted,
showLastPixel: user.showLastPixel,
timeoutUntil: user.timeoutUntil.toISOString(),
allianceId: user.allianceId,
allianceRole: user.allianceRole
};
}
async updateUser(userId: number, input: UpdateUserInput) {
const { name, showLastPixel, discord } = input;
if (name && name.length > 16) {
throw new Error("The name has more than 16 characters");
}
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (showLastPixel !== undefined) updateData.showLastPixel = showLastPixel;
if (discord !== undefined) updateData.discord = discord;
await this.prisma.user.update({
where: { id: userId },
data: updateData
});
return { success: true };
}
async getProfilePictures(userId: number) {
return await this.prisma.profilePicture.findMany({
where: { userId },
select: {
id: true,
url: true
}
});
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Request } from "@tinyhttp/app";
export interface AuthenticatedRequest extends Request {
user?: {
id: number;
sessionId: string;
};
file?: {
filename: string;
path: string;
mimetype: string;
size: number;
};
}
export interface ColorPalette {
[key: number]: {
rgb: [number, number, number];
paid: boolean;
};
}
export interface BitMap {
bytes: Uint8Array;
set(index: number, value: boolean): void;
get(index: number): boolean;
toBase64(): string;
}
export enum UserRole {
User = "user",
Admin = "admin",
Moderator = "moderator",
GlobalModerator = "global_moderator"
}
export enum BanReason {
Doxxing = "doxxing",
Bot = "bot",
HateSpeech = "hate-speech",
MultiAccounting = "multi-accounting",
InappropriateContent = "inappropriate-content",
Other = "other"
}
+53
View File
@@ -0,0 +1,53 @@
import { BitMap } from "../types/index.js";
export class WplaceBitMap implements BitMap {
bytes: Uint8Array;
constructor(bytes?: Uint8Array) {
this.bytes = bytes || new Uint8Array(0);
}
set(index: number, value: boolean): void {
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
if (byteIndex >= this.bytes.length) {
const newBytes = new Uint8Array(byteIndex + 1);
const offset = newBytes.length - this.bytes.length;
for (let i = 0; i < this.bytes.length; i++) {
newBytes[i + offset] = this.bytes[i]!;
}
this.bytes = newBytes;
}
const realIndex = this.bytes.length - 1 - byteIndex;
if (value) {
this.bytes[realIndex]! |= (1 << bitIndex);
} else {
this.bytes[realIndex]! &= ~(1 << bitIndex);
}
}
get(index: number): boolean {
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
if (byteIndex >= this.bytes.length) {
return false;
}
const realIndex = this.bytes.length - 1 - byteIndex;
return (this.bytes[realIndex]! & (1 << bitIndex)) !== 0;
}
toBase64(): string {
return Buffer.from(this.bytes)
.toString("base64");
}
static fromBase64(base64: string): WplaceBitMap {
const bytes = new Uint8Array(Buffer.from(base64, "base64"));
return new WplaceBitMap(bytes);
}
}
+10
View File
@@ -0,0 +1,10 @@
export function calculateChargeRecharge(currentCharges: number, maxCharges: number, lastUpdate: Date, cooldownMs: number): number {
if (currentCharges >= maxCharges) {
return currentCharges;
}
const timeSinceLastUpdate = Date.now() - lastUpdate.getTime();
const chargesGenerated = Math.floor(timeSinceLastUpdate / cooldownMs);
return Math.min(maxCharges, currentCharges + chargesGenerated);
}
+77
View File
@@ -0,0 +1,77 @@
import { ColorPalette } from "../types/index.js";
export const COLOR_PALETTE: ColorPalette = {
0: { rgb: [0, 0, 0], paid: false }, // Transparent
1: { rgb: [0, 0, 0], paid: false },
2: { rgb: [60, 60, 60], paid: false },
3: { rgb: [120, 120, 120], paid: false },
4: { rgb: [210, 210, 210], paid: false },
5: { rgb: [255, 255, 255], paid: false },
6: { rgb: [96, 0, 24], paid: false },
7: { rgb: [237, 28, 36], paid: false },
8: { rgb: [255, 127, 39], paid: false },
9: { rgb: [246, 170, 9], paid: false },
10: { rgb: [249, 221, 59], paid: false },
11: { rgb: [255, 250, 188], paid: false },
12: { rgb: [14, 185, 104], paid: false },
13: { rgb: [19, 230, 123], paid: false },
14: { rgb: [135, 255, 94], paid: false },
15: { rgb: [12, 129, 110], paid: false },
16: { rgb: [16, 174, 166], paid: false },
17: { rgb: [19, 225, 190], paid: false },
18: { rgb: [40, 80, 158], paid: false },
19: { rgb: [64, 147, 228], paid: false },
20: { rgb: [96, 247, 242], paid: false },
21: { rgb: [107, 80, 246], paid: false },
22: { rgb: [153, 177, 251], paid: false },
23: { rgb: [120, 12, 153], paid: false },
24: { rgb: [170, 56, 185], paid: false },
25: { rgb: [224, 159, 249], paid: false },
26: { rgb: [203, 0, 122], paid: false },
27: { rgb: [236, 31, 128], paid: false },
28: { rgb: [243, 141, 169], paid: false },
29: { rgb: [104, 70, 52], paid: false },
30: { rgb: [149, 104, 42], paid: false },
31: { rgb: [248, 178, 119], paid: false },
32: { rgb: [170, 170, 170], paid: true },
33: { rgb: [165, 14, 30], paid: true },
34: { rgb: [250, 128, 114], paid: true },
35: { rgb: [228, 92, 26], paid: true },
36: { rgb: [214, 181, 148], paid: true },
37: { rgb: [156, 132, 49], paid: true },
38: { rgb: [197, 173, 49], paid: true },
39: { rgb: [232, 212, 95], paid: true },
40: { rgb: [74, 107, 58], paid: true },
41: { rgb: [90, 148, 74], paid: true },
42: { rgb: [132, 197, 115], paid: true },
43: { rgb: [15, 121, 159], paid: true },
44: { rgb: [187, 250, 242], paid: true },
45: { rgb: [125, 199, 255], paid: true },
46: { rgb: [77, 49, 184], paid: true },
47: { rgb: [74, 66, 132], paid: true },
48: { rgb: [122, 113, 196], paid: true },
49: { rgb: [181, 174, 241], paid: true },
50: { rgb: [219, 164, 99], paid: true },
51: { rgb: [209, 128, 81], paid: true },
52: { rgb: [255, 197, 165], paid: true },
53: { rgb: [155, 82, 73], paid: true },
54: { rgb: [209, 128, 120], paid: true },
55: { rgb: [250, 182, 164], paid: true },
56: { rgb: [123, 99, 82], paid: true },
57: { rgb: [156, 132, 107], paid: true },
58: { rgb: [51, 57, 65], paid: true },
59: { rgb: [109, 117, 141], paid: true },
60: { rgb: [179, 185, 209], paid: true },
61: { rgb: [109, 100, 63], paid: true },
62: { rgb: [148, 140, 107], paid: true },
63: { rgb: [205, 197, 158], paid: true }
};
export const checkColorUnlocked = (colorId: number, extraColorsBitmap: number): boolean => {
if (colorId < 32) {
return true;
}
const mask = 1 << (colorId - 32);
return (extraColorsBitmap & mask) !== 0;
};
+12
View File
@@ -0,0 +1,12 @@
export const calculateLevel = (totalPainted: number): number => {
const base = Math.pow(30, 0.65);
return Math.pow(totalPainted, 0.65) / base;
};
export const calculateDropletsForLevel = (level: number): number => {
return Math.floor(level) * 500;
};
export const calculateMaxChargesForLevel = (level: number): number => {
return 20 + (Math.floor(level) * 2);
};
+39
View File
@@ -0,0 +1,39 @@
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
ALREADY_REPORTED: 208,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
INTERNAL_SERVER_ERROR: 500
} as const;
export const ERROR_MESSAGES = {
BAD_REQUEST: "Bad Request",
UNAUTHORIZED: "Unauthorized",
FORBIDDEN: "Forbidden",
NOT_FOUND: "Not Found",
INTERNAL_SERVER_ERROR: "Internal Server Error",
USER_NOT_FOUND: "User not found",
NO_ALLIANCE: "No Alliance",
ALREADY_IN_ALLIANCE: "Already in alliance",
ALLIANCE_NAME_TAKEN: "Alliance name taken",
INVALID_INVITE: "Invalid invite",
ALREADY_REPORTED: "Already Reported",
REFRESH_TOKEN: "refresh"
} as const;
export function createErrorResponse(message: string, status: number) {
return { error: message, status };
}
export function createSuccessResponse(data?: any) {
if (data === undefined) {
return { success: true };
}
return data;
}
+17
View File
@@ -0,0 +1,17 @@
export function validateSeason(season: string): boolean {
return season === "s0";
}
export function validateTileCoordinates(tileX: number, tileY: number): boolean {
return Number.isInteger(tileX) && Number.isInteger(tileY);
}
export function validatePixelCoordinates(x: number, y: number): boolean {
return Number.isInteger(x) && Number.isInteger(y) &&
x >= 0 && x < 1000 &&
y >= 0 && y < 1000;
}
export function validatePaginationPage(page: number): boolean {
return Number.isInteger(page) && page >= 0;
}
+57
View File
@@ -0,0 +1,57 @@
import { validatePixelCoordinates, validateSeason, validateTileCoordinates } from "./common.js";
export interface PaintPixelsValidationInput {
season: string;
tileX: number;
tileY: number;
colors: any;
coords: any;
}
export function validatePaintPixels(input: PaintPixelsValidationInput): string | null {
const { season, tileX, tileY, colors, coords } = input;
if (!validateSeason(season)) {
return "Bad Request";
}
if (!validateTileCoordinates(tileX, tileY)) {
return "Bad Request";
}
if (!colors || !coords || !Array.isArray(colors) || !Array.isArray(coords)) {
return "Bad Request";
}
if (colors.length * 2 !== coords.length) {
return "Bad Request";
}
return null;
}
export interface PixelInfoValidationInput {
season: string;
tileX: number;
tileY: number;
x: number;
y: number;
}
export function validatePixelInfo(input: PixelInfoValidationInput): string | null {
const { season, tileX, tileY, x, y } = input;
if (!validateSeason(season)) {
return "Bad Request";
}
if (!validateTileCoordinates(tileX, tileY)) {
return "Bad Request";
}
if (!validatePixelCoordinates(x, y)) {
return "Bad Request";
}
return null;
}
+15
View File
@@ -0,0 +1,15 @@
export interface UpdateUserValidationInput {
name?: string;
showLastPixel?: boolean;
discord?: string;
}
export function validateUpdateUser(input: UpdateUserValidationInput): string | null {
const { name } = input;
if (name && name.length > 16) {
return "The name has more than 16 characters";
}
return null;
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": ["@tsconfig/node24", "@tsconfig/strictest"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
setupFiles: ["./tests/setup.ts"]
}
});