├── Procfile ├── docs ├── DbSchema.png └── DbSchema.drawio ├── src ├── api │ ├── get.ts │ ├── health │ │ └── get.ts │ ├── projects │ │ ├── verification │ │ │ ├── verificationStore.ts │ │ │ ├── get.ts │ │ │ └── post.ts │ │ ├── features │ │ │ └── get.ts │ │ ├── images │ │ │ └── get.ts │ │ ├── collaborators │ │ │ ├── get.ts │ │ │ └── delete.ts │ │ ├── launch │ │ │ └── {year} │ │ │ │ └── get.ts │ │ ├── delete.ts │ │ ├── get.ts │ │ ├── tags │ │ │ ├── get.ts │ │ │ ├── delete.ts │ │ │ └── post.ts │ │ ├── id │ │ │ └── {id} │ │ │ │ └── get.ts │ │ ├── post.ts │ │ └── put.ts │ ├── bot │ │ ├── user │ │ │ └── {discordId} │ │ │ │ ├── get.ts │ │ │ │ └── roles │ │ │ │ ├── get.ts │ │ │ │ ├── delete.ts │ │ │ │ └── put.ts │ │ └── project │ │ │ └── roles │ │ │ └── post.ts │ ├── user │ │ ├── {discordId} │ │ │ ├── get.ts │ │ │ └── projects │ │ │ │ └── get.ts │ │ ├── put.ts │ │ ├── post.ts │ │ └── delete.ts │ └── signin │ │ ├── refresh │ │ └── get.ts │ │ └── redirect │ │ └── get.ts ├── bot │ ├── commands │ │ ├── ping.ts │ │ ├── project.ts │ │ ├── autoregister.ts │ │ ├── portal.ts │ │ ├── role.ts │ │ ├── news.ts │ │ ├── getuser.ts │ │ ├── staffpoll.ts │ │ └── infraction.ts │ └── events │ │ ├── messageUpdate.ts │ │ ├── messageCreate.ts │ │ ├── messageDelete.ts │ │ ├── guildMemberRemove.ts │ │ └── messageHandlers │ │ ├── devChatterWarning.ts │ │ └── swearFilter.ts ├── models │ ├── ProjectFeature.ts │ ├── ProjectImage.ts │ ├── ProjectTag.ts │ ├── Tag.ts │ ├── Category.ts │ ├── Role.ts │ ├── User.ts │ ├── UserProject.ts │ ├── types.ts │ └── Project.ts ├── common │ ├── helpers │ │ ├── responseHelper.ts │ │ ├── generic.ts │ │ └── discord.ts │ └── sequalize.ts ├── index.ts └── api.yaml ├── tasks └── cleanup.js ├── README.md ├── .gitignore ├── tsconfig.json ├── .vscode └── launch.json └── package.json /Procfile: -------------------------------------------------------------------------------- 1 | web: node ./build/index.js -------------------------------------------------------------------------------- /docs/DbSchema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindowsAppCommunity/uwpcommunity-backend/HEAD/docs/DbSchema.png -------------------------------------------------------------------------------- /src/api/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | module.exports = (req: Request, res: Response) => { 4 | res.redirect(`https://uwpcommunity.com`); 5 | } 6 | -------------------------------------------------------------------------------- /src/api/health/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | module.exports = (req: Request, res: Response) => { 4 | console.log("received"); 5 | res.status(200); 6 | } 7 | -------------------------------------------------------------------------------- /src/api/projects/verification/verificationStore.ts: -------------------------------------------------------------------------------- 1 | export interface IVerificationStore { 2 | code: number; 3 | storeId: string; 4 | } 5 | 6 | export let verificationStorage: IVerificationStore[] = []; -------------------------------------------------------------------------------- /src/bot/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel } from "discord.js"; 2 | import { IBotCommandArgument } from "../../models/types"; 3 | 4 | export default async (message: Message, commandParts: string[], args: IBotCommandArgument[]) => (message.channel as TextChannel).send("pong"); -------------------------------------------------------------------------------- /src/bot/commands/project.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | import { IBotCommandArgument } from "../../models/types"; 3 | import app from './app'; 4 | 5 | export default async (message: Message, commandParts: string[], args: IBotCommandArgument[]) => app(message, commandParts, args); -------------------------------------------------------------------------------- /tasks/cleanup.js: -------------------------------------------------------------------------------- 1 | const pathsToClean = ["./build"]; 2 | const fs = require('fs-extra') 3 | 4 | for (let path of pathsToClean) { 5 | if (fs.pathExistsSync(path)) { 6 | console.log(`Cleaning up "${path}"`); 7 | fs.removeSync(path); 8 | } 9 | } 10 | 11 | if (pathsToClean.length > 0) console.log(`Done`); 12 | -------------------------------------------------------------------------------- /src/bot/events/messageUpdate.ts: -------------------------------------------------------------------------------- 1 | import { PartialMessage } from "discord.js"; 2 | import { handleSwearFilter } from "./messageHandlers/swearFilter"; 3 | 4 | export default (oldDiscordMessage: PartialMessage, newDiscordMessage: PartialMessage) => { 5 | if (oldDiscordMessage.author?.bot) 6 | return; // ignore messages sent by bots. 7 | 8 | handleSwearFilter(newDiscordMessage); 9 | } 10 | -------------------------------------------------------------------------------- /src/bot/events/messageCreate.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | import { devChatterWarning } from "./messageHandlers/devChatterWarning"; 3 | import { handleSwearFilter } from "./messageHandlers/swearFilter"; 4 | 5 | export default (discordMessage: Message) => { 6 | if (discordMessage.author?.bot) 7 | return; // ignore messages sent by bots. 8 | 9 | handleSwearFilter(discordMessage); 10 | devChatterWarning(discordMessage); 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UWP Community Backend 2 | 3 | ## Setting up a development environment 4 | 5 | 1. Clone the repo 6 | 7 | 2. Run `npm install` in the project directory 8 | 9 | 3. Create .env file in the root with the following values (replace empty strings with values from an administrator) 10 | 11 | ```text 12 | SENDGRID_API_KEY="" 13 | DATABASE_URL="" 14 | discord_client="" 15 | discord_secret="" 16 | discord_botToken="" 17 | environment="development" 18 | ``` 19 | 20 | 4. Run `npm start` 21 | -------------------------------------------------------------------------------- /src/models/ProjectFeature.ts: -------------------------------------------------------------------------------- 1 | import { Column, Model, Table, PrimaryKey, AutoIncrement, DataType } from 'sequelize-typescript'; 2 | 3 | @Table 4 | export default class ProjectFeature extends Model { 5 | @PrimaryKey 6 | @AutoIncrement 7 | @Column(DataType.INTEGER) 8 | declare id: number; 9 | 10 | @Column 11 | projectId!: number; 12 | 13 | @Column 14 | feature!: string; 15 | } 16 | 17 | export async function getFeaturesForProject(projectId: number): Promise { 18 | const features = await ProjectFeature.findAll({ where: { projectId: projectId } }); 19 | 20 | return features.map(x => x.feature); 21 | } 22 | -------------------------------------------------------------------------------- /src/models/ProjectImage.ts: -------------------------------------------------------------------------------- 1 | import { Column, Model, Table, ForeignKey, PrimaryKey, AutoIncrement, DataType, BelongsTo } from 'sequelize-typescript'; 2 | 3 | @Table 4 | export default class ProjectImage extends Model { 5 | @PrimaryKey 6 | @AutoIncrement 7 | @Column(DataType.INTEGER) 8 | declare id: number; 9 | 10 | @Column 11 | projectId!: number; 12 | 13 | @Column 14 | imageUrl!: string; 15 | } 16 | 17 | export async function getImagesForProject(projectId: number): Promise { 18 | const projectImages = await ProjectImage.findAll({ where: { projectId: projectId } }); 19 | 20 | return projectImages.map(x => x.imageUrl); 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.log 5 | *.orig 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *.zip 11 | *~ 12 | _key.* 13 | 14 | # OS or Editor folders 15 | ._* 16 | .DS_Store 17 | .idea 18 | .project 19 | .settings 20 | .tmproj 21 | *.esproj 22 | *.sublime-project 23 | *.sublime-workspace 24 | nbproject 25 | Thumbs.db 26 | desktop.ini 27 | 28 | # Vendor Files 29 | node_modules/ 30 | bower_components/ 31 | 32 | # Debug Files 33 | npm-debug.* 34 | .env 35 | 36 | # Temp Files 37 | .cache/ 38 | .tmp/ 39 | .rpt2_cache/ 40 | spec/out/ 41 | 42 | # Safe files 43 | _key* 44 | /.vs 45 | 46 | # Build files 47 | build/ 48 | 49 | *[cC]ache*.json 50 | -------------------------------------------------------------------------------- /src/api/projects/features/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { HttpStatus, BuildResponse } from "../../../common/helpers/responseHelper"; 3 | import { getFeaturesForProject } from "../../../models/ProjectFeature"; 4 | 5 | module.exports = async (req: Request, res: Response) => { 6 | const reqQuery = req.query as IGetProjectFeaturesRequestQuery; 7 | 8 | if(!reqQuery.projectId) { 9 | BuildResponse(res, HttpStatus.MalformedRequest, `id not provided or malformed`); 10 | return; 11 | } 12 | 13 | var images = await getFeaturesForProject(reqQuery.projectId); 14 | 15 | BuildResponse(res, HttpStatus.Success, images); 16 | }; 17 | 18 | interface IGetProjectFeaturesRequestQuery { 19 | projectId: number; 20 | } -------------------------------------------------------------------------------- /src/api/projects/images/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { HttpStatus, BuildResponse } from "../../../common/helpers/responseHelper"; 3 | import ProjectImage, { getImagesForProject } from "../../../models/ProjectImage"; 4 | 5 | module.exports = async (req: Request, res: Response) => { 6 | const reqQuery = req.query as IGetProjectImagesRequestQuery; 7 | 8 | if(!reqQuery.projectId) { 9 | BuildResponse(res, HttpStatus.MalformedRequest, `id not provided or malformed`); 10 | return; 11 | } 12 | 13 | var images = await getImagesForProject(reqQuery.projectId); 14 | 15 | BuildResponse(res, HttpStatus.Success, images); 16 | }; 17 | 18 | interface IGetProjectImagesRequestQuery { 19 | projectId: number; 20 | } -------------------------------------------------------------------------------- /src/models/ProjectTag.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreatedAt, Model, Table, UpdatedAt, HasMany, PrimaryKey, AutoIncrement, DataType, ForeignKey } from 'sequelize-typescript'; 2 | import Project from './Project'; 3 | import Tag from './Tag'; 4 | 5 | @Table 6 | export default class ProjectTag extends Model { 7 | @PrimaryKey 8 | @AutoIncrement 9 | @Column(DataType.INTEGER) 10 | declare id: number; 11 | 12 | @Column(DataType.INTEGER) 13 | @ForeignKey(() => Project) 14 | projectId!: number; 15 | 16 | @Column(DataType.INTEGER) 17 | @ForeignKey(() => Tag) 18 | tagId!: number; 19 | 20 | @CreatedAt 21 | @Column 22 | declare createdAt: Date; 23 | 24 | @UpdatedAt 25 | @Column 26 | declare updatedAt: Date; 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "lib": [ 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": false, 14 | "strictPropertyInitialization": false, 15 | "module": "commonjs", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "outDir": "./build", 19 | "rootDir": "./src", 20 | "sourceMap": true, 21 | "removeComments": false 22 | }, 23 | "include": [ 24 | "./src/**/*" 25 | ], 26 | "exclude": [ 27 | "./node_modules" 28 | ] 29 | } -------------------------------------------------------------------------------- /src/bot/events/messageDelete.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel } from "discord.js"; 2 | import { GetChannelByName } from "../../common/helpers/discord"; 3 | 4 | export default async (discordMessage: Message) => { 5 | if (discordMessage.author?.bot) 6 | return; // ignore messages sent by bots. 7 | 8 | // Does not work for really old messages 9 | const botstuffChannel = await GetChannelByName("bot-stuff") as TextChannel; 10 | 11 | if ((discordMessage.channel as TextChannel).name === "mod-chat" || (discordMessage.channel as TextChannel).name === "infraction-log" || (discordMessage.channel as TextChannel).name === "bot-stuff") { 12 | return; 13 | } 14 | 15 | botstuffChannel.send(`Message from <@${discordMessage.author.id}> was deleted from ${(discordMessage.channel as TextChannel).name}:\n> ${discordMessage.content}`) 16 | } 17 | -------------------------------------------------------------------------------- /src/api/bot/user/{discordId}/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { GetUser } from "../../../../common/helpers/discord"; 3 | import { BuildResponse, HttpStatus } from "../../../../common/helpers/responseHelper"; 4 | import { validateAuthenticationHeader, genericServerError } from "../../../../common/helpers/generic"; 5 | import { User } from "discord.js"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | const authAccess = validateAuthenticationHeader(req, res); 9 | if (!authAccess) return; 10 | 11 | const user = await GetUser(req.params['discordId']).catch((err) => BuildResponse(res, HttpStatus.MalformedRequest, "Invalid discord ID: " + err)); 12 | 13 | if (!user) { 14 | BuildResponse(res, HttpStatus.InternalServerError, "Couldn't get user."); 15 | return; 16 | } 17 | 18 | delete (user as any).lastMessageID; 19 | delete (user as any).lastMessage; 20 | BuildResponse(res, HttpStatus.Success, user as User); 21 | } 22 | -------------------------------------------------------------------------------- /src/models/Tag.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreatedAt, Model, Table, UpdatedAt, PrimaryKey, AutoIncrement, DataType, HasMany, BelongsToMany } from 'sequelize-typescript'; 2 | import Project, { DbToStdModal_Project } from './Project'; 3 | import ProjectTag from './ProjectTag'; 4 | import { ITag } from './types'; 5 | 6 | @Table 7 | export default class Tag extends Model { 8 | @PrimaryKey 9 | @AutoIncrement 10 | @Column(DataType.INTEGER) 11 | declare id: number; 12 | 13 | @Column 14 | name!: string; 15 | 16 | @Column 17 | icon!: string; 18 | 19 | @BelongsToMany(() => Project, () => ProjectTag) 20 | projects: Project[]; 21 | 22 | @CreatedAt 23 | @Column 24 | declare createdAt: Date; 25 | 26 | @UpdatedAt 27 | @Column 28 | declare updatedAt: Date; 29 | } 30 | 31 | export function DbToStdModal_Tag(tag: Tag): ITag { 32 | return { 33 | id: tag.id, 34 | name: tag.name, 35 | icon: tag.icon, 36 | projects: tag.projects?.map(DbToStdModal_Project) ?? [], 37 | } 38 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Node / TS", 11 | "cwd": "${workspaceFolder}/build", 12 | "envFile": "${workspaceFolder}/.env", 13 | "outFiles": [ 14 | "${workspaceFolder}/build/**/*.js" 15 | ], 16 | "skipFiles": [ 17 | "${workspaceFolder}/node_modules/**/*.js", 18 | ], 19 | "runtimeArgs": [ 20 | "--inspect" 21 | ], 22 | "sourceMaps": true, 23 | "program": "${workspaceFolder}/build/index.js", 24 | "preLaunchTask": "npm: build", 25 | "console": "internalConsole", 26 | "trace": true 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /src/common/helpers/responseHelper.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import { camelCaseToSpacedString } from "./generic"; 3 | 4 | export enum HttpStatus { 5 | BadRequest = 400, 6 | Unauthorized = 401, 7 | NotFound = 404, 8 | MalformedRequest = 422, 9 | InternalServerError = 500, 10 | Success = 200 11 | } 12 | 13 | export interface IRequestPromiseReject { 14 | reason: string; 15 | status: HttpStatus; 16 | } 17 | export function ResponsePromiseReject(reason: string, status: HttpStatus, reject: (reason: IRequestPromiseReject) => void): any { 18 | reject({ reason, status }); 19 | } 20 | 21 | export function BuildResponse(res: Response, status: HttpStatus, body?: string | object): Response { 22 | if (status === HttpStatus.Success) { 23 | SendResponse(res, status, body); 24 | } else { 25 | SendResponse(res, status, { 26 | error: camelCaseToSpacedString(HttpStatus[status]), 27 | reason: body 28 | }); 29 | } 30 | 31 | return res; 32 | } 33 | 34 | function SendResponse(res: Response, status: any, body: any): Response { 35 | res.status(status); 36 | res.send(body); 37 | return res; 38 | } -------------------------------------------------------------------------------- /src/models/Category.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreatedAt, Model, Table, UpdatedAt, PrimaryKey, AutoIncrement, DataType, HasMany } from 'sequelize-typescript'; 2 | import Project from './Project'; 3 | 4 | 5 | /** @summary Due to time contraints, this is no longer in use. Might be re-enabled again in the future if needed */ 6 | @Table 7 | export default class Category extends Model { 8 | 9 | @PrimaryKey 10 | @AutoIncrement 11 | @Column(DataType.INTEGER) 12 | declare id: number; 13 | 14 | @Column 15 | name!: string; 16 | 17 | @HasMany(() => Project, 'categoryId') 18 | projects?: Project[]; 19 | 20 | @CreatedAt 21 | @Column 22 | declare createdAt: Date; 23 | 24 | @UpdatedAt 25 | @Column 26 | declare updatedAt: Date; 27 | } 28 | 29 | async function GetCategoryIdFromName(name: string): Promise { 30 | const catDb = await Category.findOne({ where: { name: name } }).catch(() => { }); 31 | if (!catDb) return; 32 | return (catDb.id); 33 | } 34 | 35 | async function GetCategoryNameFromId(id: number): Promise { 36 | const catDb = await Category.findOne({ where: { id: id } }).catch(() => { }); 37 | if (!catDb) return; 38 | return (catDb.name); 39 | } 40 | -------------------------------------------------------------------------------- /src/api/bot/user/{discordId}/roles/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express-serve-static-core"; 2 | import { GetGuildUser, GetDiscordUser } from "../../../../../common/helpers/discord"; 3 | import { genericServerError, validateAuthenticationHeader, DEVENV } from "../../../../../common/helpers/generic"; 4 | import { BuildResponse, HttpStatus, } from "../../../../../common/helpers/responseHelper"; 5 | 6 | module.exports = async (req: Request, res: Response) => { 7 | const authAccess = validateAuthenticationHeader(req, res); 8 | if (!authAccess) return; 9 | 10 | const user = await GetDiscordUser(authAccess).catch((err) => genericServerError(err, res)); 11 | if (!user) { 12 | BuildResponse(res, HttpStatus.Unauthorized, "Invalid accessToken"); 13 | return; 14 | } 15 | 16 | const guildMember = await GetGuildUser(user.id); 17 | if (!guildMember) { 18 | genericServerError("Unable to get guild details", res); 19 | return; 20 | } 21 | 22 | // Using "as any" here to silence the compiler warning that the role.guild object must be optional if deleting it. 23 | // This is only fine because data is immediately used as the response. 24 | let roles = guildMember.roles.cache.map(role => { delete (role as any).guild; return role }); 25 | BuildResponse(res, HttpStatus.Success, roles); 26 | }; -------------------------------------------------------------------------------- /src/api/user/{discordId}/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getUserByDiscordId, DbToStdModal_User } from "../../../models/User"; 3 | import { IUser, ResponseErrorReasons } from "../../../models/types"; 4 | import { genericServerError } from "../../../common/helpers/generic"; 5 | import { HttpStatus, BuildResponse } from "../../../common/helpers/responseHelper"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | let discordId = req.params['discordId']; 9 | 10 | const user: IUser | void = await GetUser(discordId).catch(err => genericServerError(err, res)); 11 | if (!user) { 12 | BuildResponse(res, HttpStatus.NotFound, ResponseErrorReasons.UserNotExists); 13 | return; 14 | } 15 | 16 | BuildResponse(res, HttpStatus.Success, user); 17 | }; 18 | 19 | function GetUser(discordId: string): Promise { 20 | return new Promise(async (resolve, reject) => { 21 | const DbUser = await getUserByDiscordId(discordId).catch(reject); 22 | if (!DbUser) { 23 | resolve(); 24 | return; 25 | } 26 | 27 | const StdUser = DbToStdModal_User(DbUser) 28 | if (StdUser == undefined || StdUser == null) { 29 | reject("Unable to convert database entry"); 30 | return; 31 | }; 32 | resolve(StdUser); 33 | }); 34 | } -------------------------------------------------------------------------------- /src/api/signin/refresh/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { HttpStatus, BuildResponse } from "../../../common/helpers/responseHelper"; 3 | const request = require("request"); 4 | 5 | function log(...args: any[]) { 6 | console.log(`GET /signin/: \x1b[33m${Array.from(arguments)}\x1b[0m`); 7 | } 8 | 9 | module.exports = (req: Request, res: Response) => { 10 | if (!req.query.refreshToken) { 11 | BuildResponse(res, HttpStatus.MalformedRequest, "Missing refreshToken"); 12 | return; 13 | } 14 | 15 | let refreshToken = req.query.refreshToken; 16 | request.post({ 17 | url: 'https://discordapp.com/api/oauth2/token', 18 | form: { 19 | client_id: process.env.discord_client, 20 | client_secret: process.env.discord_secret, 21 | grant_type: "refresh_token", 22 | refresh_token: refreshToken, 23 | redirect_uri: "http://uwpcommunity-site-backend.herokuapp.com/signin/redirect", 24 | scope: "identify guilds" 25 | } 26 | }, (err: Error, httpResponse: any, body: string) => { 27 | BuildResponse(res, HttpStatus.Success, JSON.stringify(body)); 28 | }); 29 | }; 30 | 31 | 32 | interface IDiscordAuthResponse { 33 | "access_token": string; 34 | "token_type": "Bearer" 35 | "expires_in": number, 36 | "refresh_token": string, 37 | "scope": string; 38 | }; -------------------------------------------------------------------------------- /src/api/projects/collaborators/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { IProjectCollaborator } from "../../../models/types"; 3 | import { genericServerError } from "../../../common/helpers/generic"; 4 | import { GetProjectCollaborators } from "../../../models/UserProject"; 5 | 6 | module.exports = (req: Request, res: Response) => { 7 | const queryCheck = checkQuery(req.query); 8 | if (queryCheck !== true) { 9 | res.status(422); 10 | res.json({ 11 | error: "Malformed request", 12 | reason: `Query string "${queryCheck}" not provided or malformed` 13 | }); 14 | return; 15 | } 16 | 17 | getProjectCollaborators(req.query, res) 18 | .then(result => { 19 | res.send(result); 20 | }) 21 | .catch(err => genericServerError(err, res)); 22 | }; 23 | 24 | export async function getProjectCollaborators(projectRequestData: IGetProjectsRequestQuery, res: Response): Promise { 25 | const collaborators: IProjectCollaborator[] | void = await GetProjectCollaborators(projectRequestData.projectId).catch(err => genericServerError(err, res)); 26 | return collaborators; 27 | } 28 | 29 | function checkQuery(query: IGetProjectsRequestQuery) { 30 | if (query.projectId == undefined) return "projectId"; 31 | return true; 32 | } 33 | 34 | interface IGetProjectsRequestQuery { 35 | projectId: number; 36 | } 37 | -------------------------------------------------------------------------------- /src/models/Role.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreatedAt, Model, Table, UpdatedAt, HasMany, PrimaryKey, AutoIncrement, DataType } from 'sequelize-typescript'; 2 | import role from '../bot/commands/role'; 3 | import UserProject from './UserProject'; 4 | 5 | @Table 6 | export default class Role extends Model { 7 | @PrimaryKey 8 | @AutoIncrement 9 | @Column(DataType.INTEGER) 10 | declare id: number; 11 | 12 | @Column 13 | name!: "Developer" | "Translator" | "Beta Tester" | "Support" | "Lead" | "Patreon" | "Advocate" | "Other"; 14 | 15 | @CreatedAt 16 | @Column 17 | declare createdAt: Date; 18 | 19 | @UpdatedAt 20 | @Column 21 | declare updatedAt: Date; 22 | } 23 | 24 | export async function GetRoleByName(roleName: string): Promise { 25 | return Role.findOne({ where: { name: InputtedUserTypeToDBRoleType(roleName) ?? roleName } }).catch(Promise.reject); 26 | } 27 | 28 | export function InputtedUserTypeToDBRoleType(inputtedRole: string): string | undefined { 29 | switch (inputtedRole) { 30 | case "tester": 31 | return "Beta Tester"; 32 | case "translator": 33 | return "Translator"; 34 | case "dev": 35 | return "Developer"; 36 | case "advocate": 37 | return "Advocate"; 38 | case "support": 39 | return "Support"; 40 | case "lead": 41 | return "Lead"; 42 | case "patreon": 43 | return "Patreon"; 44 | case "other": 45 | return "Other"; 46 | default: 47 | return undefined; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/api/projects/launch/{year}/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import Project, { DbToStdModal_Project, getAllDbProjects, getAllProjects } from "../../../../models/Project"; 3 | import { IProject, IProjects } from "../../../../models/types"; 4 | import { HttpStatus, BuildResponse, ResponsePromiseReject, IRequestPromiseReject } from "../../../../common/helpers/responseHelper"; 5 | 6 | module.exports = async (req: Request, res: Response) => { 7 | const reqQuery = req.params as IGetProjectRequestQuery; 8 | 9 | getProjectByLaunchYear(reqQuery.year as string) 10 | .then(results => { 11 | BuildResponse(res, HttpStatus.Success, results); 12 | }) 13 | .catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 14 | }; 15 | 16 | export function getProjectByLaunchYear(year: string): Promise { 17 | return new Promise(async (resolve, reject) => { 18 | // get all projects 19 | const allDbProjects = await getAllDbProjects(); 20 | 21 | // find only the ones for the given launch year. 22 | var projectsFromLaunchYear = allDbProjects.filter(x => x.tags?.filter(tag => tag.name == `Launch ${year}`).length ?? 0 > 0); 23 | 24 | // filter out private projects 25 | var publicLaunchProjects = projectsFromLaunchYear.filter(x => !x.isPrivate); 26 | 27 | resolve({ 28 | projects: publicLaunchProjects.map(DbToStdModal_Project), 29 | privateCount: projectsFromLaunchYear.length - publicLaunchProjects.length, 30 | }); 31 | }); 32 | } 33 | 34 | interface IGetProjectRequestQuery { 35 | year?: string; 36 | } -------------------------------------------------------------------------------- /src/api/projects/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import Project, { getAllDbProjects, getAllProjects, nukeProject, RefreshProjectCache } from "../../models/Project"; 3 | import { validateAuthenticationHeader } from "../../common/helpers/generic"; 4 | import { GetDiscordIdFromToken, GetGuildUser } from "../../common/helpers/discord"; 5 | import { HttpStatus, BuildResponse, ResponsePromiseReject, IRequestPromiseReject } from "../../common/helpers/responseHelper"; 6 | import UserProject, { GetProjectCollaborators } from "../../models/UserProject"; 7 | import ProjectImage from "../../models/ProjectImage"; 8 | import ProjectTag from "../../models/ProjectTag"; 9 | 10 | module.exports = async (req: Request, res: Response) => { 11 | const bodyCheck = checkBody(req.body); 12 | if (bodyCheck !== true) { 13 | BuildResponse(res, HttpStatus.MalformedRequest, `Query string "${bodyCheck}" not provided or malformed`); 14 | return; 15 | } 16 | 17 | const authAccess = validateAuthenticationHeader(req, res); 18 | if (!authAccess) return; 19 | 20 | let discordId = await GetDiscordIdFromToken(authAccess, res); 21 | if (!discordId) return; 22 | 23 | nukeProject((req.body as IDeleteProjectsRequestBody).appName, discordId) 24 | .then(() => { 25 | BuildResponse(res, HttpStatus.Success, "Success"); 26 | RefreshProjectCache(); 27 | }) 28 | .catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 29 | }; 30 | 31 | function checkBody(body: IDeleteProjectsRequestBody): true | string { 32 | if (!body.appName) return "appName"; 33 | return true; 34 | } 35 | 36 | 37 | interface IDeleteProjectsRequestBody { 38 | appName: string; 39 | } -------------------------------------------------------------------------------- /src/bot/commands/autoregister.ts: -------------------------------------------------------------------------------- 1 | import { IBotCommandArgument } from "../../models/types"; 2 | import { DMChannel, Message, TextChannel } from "discord.js"; 3 | import User, { getUserByDiscordId } from "../../models/User"; 4 | 5 | 6 | export default async (message: Message, commandParts: string[], args: IBotCommandArgument[]) => { 7 | var discordUser = message.author; 8 | 9 | var name = args.find(x => x.name == "name")?.value; 10 | var email = args.find(x => x.name == "email")?.value; 11 | 12 | // Check if the user already exists 13 | const user = await User.findOne({ 14 | where: { discordId: discordUser.id } 15 | }) 16 | .catch((err) => handleGenericError(err, (message.channel as TextChannel))); 17 | 18 | if (user) { 19 | (message.channel as TextChannel).send(`User is already registered.`); 20 | return; 21 | } 22 | 23 | if (!name) { 24 | (message.channel as TextChannel).send(`Please supply a name as an argument. E.g. \`!autoregister /name "Average Joe"\`. This name will be displayed to other users.`); 25 | return; 26 | } 27 | 28 | await submitUser(name, discordUser.id, email); 29 | 30 | (message.channel as TextChannel).send(`Thank you for registering! You can now access community services, the dashboard on https://uwpcommunity.com/, and (when given access) private app channels.`); 31 | } 32 | 33 | function submitUser(name: string, discordId: string, email: string | undefined): Promise { 34 | return new Promise((resolve, reject) => { 35 | User.create({ name, email, discordId }) 36 | .then(resolve) 37 | .catch(reject); 38 | }); 39 | } 40 | 41 | function handleGenericError(channel: TextChannel | DMChannel, err: any) { 42 | channel.send(`An error occurred: ${err}`); 43 | } -------------------------------------------------------------------------------- /src/api/signin/redirect/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { DEVENV } from "../../../common/helpers/generic" 3 | import { BuildResponse, HttpStatus } from "../../../common/helpers/responseHelper"; 4 | 5 | const request = require("request"); 6 | 7 | function log(...args: any[]) { 8 | console.log(`GET /signin/redirect: \x1b[33m${Array.from(arguments)}\x1b[0m`); 9 | } 10 | 11 | if (process.env.discord_client === undefined || process.env.discord_secret === undefined) { 12 | log(`Missing discord_client or discord_secret env variables. Requests will most likely fail`); 13 | } 14 | 15 | /*** 16 | * @summary Exchange the code for an access token 17 | * @see https://discordapp.com/developers/docs/topics/oauth2#authorization-code-grant-redirect-url-example 18 | */ 19 | module.exports = (req: Request, res: Response) => { 20 | let code = req.query.code; 21 | 22 | if (!code) { 23 | BuildResponse(res, HttpStatus.MalformedRequest, "Missing code query"); 24 | return; 25 | } 26 | 27 | request.post({ 28 | url: 'https://discordapp.com/api/oauth2/token', 29 | form: { 30 | client_id: process.env.discord_client, 31 | client_secret: process.env.discord_secret, 32 | grant_type: "authorization_code", 33 | code: code, 34 | redirect_uri: DEVENV ? "http://localhost:5000/signin/redirect" : "http://uwpcommunity-site-backend.herokuapp.com/signin/redirect", 35 | scope: "identify guilds" 36 | } 37 | }, (err: Error, httpResponse: any, body: string) => { 38 | // This is an IDiscordAuthResponse, convert it to base64 to use in a URL 39 | let authResponse: string = Buffer.from(body).toString('base64'); 40 | 41 | res.redirect(`http://${DEVENV ? "localhost:3000" : "uwpcommunity.com"}/signin?authResponse=${authResponse}`); 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/api/user/put.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User, { getUserByDiscordId } from "../../models/User" 3 | import { genericServerError, validateAuthenticationHeader } from "../../common/helpers/generic"; 4 | import { GetDiscordIdFromToken } from "../../common/helpers/discord"; 5 | import { HttpStatus, BuildResponse } from "../../common/helpers/responseHelper"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | const body = req.body; 9 | 10 | const authAccess = validateAuthenticationHeader(req, res); 11 | if (!authAccess) return; 12 | 13 | let discordId = await GetDiscordIdFromToken(authAccess, res); 14 | if (!discordId) return; 15 | 16 | let bodyCheck = checkBody(body); 17 | if (bodyCheck !== true) { 18 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${bodyCheck}" not provided or malformed`); 19 | return; 20 | } 21 | 22 | updateUser(body, discordId) 23 | .then(() => { 24 | BuildResponse(res, HttpStatus.Success, "Success"); 25 | }) 26 | .catch((err) => genericServerError(err, res)); 27 | }; 28 | 29 | function checkBody(body: IPutUserRequestBody): true | string { 30 | if (!body.name) return "name"; 31 | return true; 32 | } 33 | 34 | 35 | function whitelistBody(body: IPutUserRequestBody) { 36 | 37 | } 38 | 39 | function updateUser(userData: IPutUserRequestBody, discordId: string): Promise { 40 | return new Promise(async (resolve, reject) => { 41 | let user = await getUserByDiscordId(discordId); 42 | 43 | if (!user) { 44 | reject("User not found"); 45 | return; 46 | } 47 | 48 | user.discordId = discordId; 49 | 50 | user.update(userData) 51 | .then(resolve) 52 | .catch(reject); 53 | }); 54 | } 55 | 56 | interface IPutUserRequestBody { 57 | name: string; 58 | email?: string; 59 | } -------------------------------------------------------------------------------- /src/api/user/post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User, { getUserByDiscordId } from "../../models/User" 3 | import { ResponseErrorReasons } from "../../models/types"; 4 | import { genericServerError, validateAuthenticationHeader } from "../../common/helpers/generic"; 5 | import { GetDiscordIdFromToken } from "../../common/helpers/discord"; 6 | import { BuildResponse, HttpStatus } from "../../common/helpers/responseHelper"; 7 | 8 | module.exports = async (req: Request, res: Response) => { 9 | const body = req.body; 10 | 11 | const authAccess = validateAuthenticationHeader(req, res); 12 | if (!authAccess) return; 13 | 14 | let discordId = await GetDiscordIdFromToken(authAccess, res); 15 | if (!discordId) return; 16 | 17 | const bodyCheck = checkBody(body); 18 | if (bodyCheck !== true) { 19 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${bodyCheck}" not provided or malformed`); 20 | return; 21 | } 22 | 23 | // Check if the user already exists 24 | const user = await getUserByDiscordId(discordId).catch((err) => genericServerError(err, res)); 25 | 26 | if (user) { 27 | BuildResponse(res, HttpStatus.BadRequest, ResponseErrorReasons.UserExists); 28 | return; 29 | } 30 | 31 | submitUser({ ...body, discordId: discordId }) 32 | .then(() => { 33 | BuildResponse(res, HttpStatus.Success, "Success"); 34 | }) 35 | .catch((err) => genericServerError(err, res)); 36 | }; 37 | 38 | function checkBody(body: IPostUserRequestBody): true | string { 39 | if (!body.name) return "name"; 40 | return true; 41 | } 42 | 43 | function submitUser(userData: IPostUserRequestBody): Promise { 44 | return new Promise((resolve, reject) => { 45 | User.create({ ...userData }) 46 | .then(resolve) 47 | .catch(reject); 48 | }); 49 | } 50 | 51 | interface IPostUserRequestBody { 52 | name: string; 53 | email?: string; 54 | } -------------------------------------------------------------------------------- /src/api/bot/user/{discordId}/roles/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express-serve-static-core"; 2 | import { GetGuildUser, GetDiscordUser, GetRoles } from "../../../../../common/helpers/discord"; 3 | import { Role } from "discord.js"; 4 | import { genericServerError, validateAuthenticationHeader } from "../../../../../common/helpers/generic"; 5 | import { BuildResponse, HttpStatus, } from "../../../../../common/helpers/responseHelper"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | const authAccess = validateAuthenticationHeader(req, res); 9 | if (!authAccess) return; 10 | 11 | const user = await GetDiscordUser(authAccess).catch((err) => genericServerError(err, res)); 12 | if (!user) { 13 | BuildResponse(res, HttpStatus.Unauthorized, "Invalid accessToken"); 14 | return; 15 | } 16 | 17 | if (req.params['discordId'] !== user.id) { 18 | BuildResponse(res, HttpStatus.Unauthorized, "Authenticated user and requested ID don't match"); 19 | return; 20 | } 21 | 22 | const guildMember = await GetGuildUser(user.id); 23 | if (!guildMember) { 24 | genericServerError("Unable to get guild details", res); 25 | return; 26 | } 27 | 28 | // Must have a role in the body (JSON) 29 | if (!req.body.name) { 30 | BuildResponse(res, HttpStatus.Unauthorized, "Missing role name"); 31 | return; 32 | } 33 | 34 | // Check that the user has the role 35 | let roles: Role[] = [...guildMember.roles.cache.filter(role => role.name == req.body.role).values()]; 36 | if (roles.length == 0) InvalidRole(res); 37 | 38 | switch (req.body.name) { 39 | case "Developer": 40 | guildMember.roles.remove(roles[0]); 41 | BuildResponse(res, HttpStatus.Success, "Success"); 42 | break; 43 | default: 44 | InvalidRole(res); 45 | } 46 | }; 47 | 48 | function InvalidRole(res: Response) { 49 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid role"); 50 | } -------------------------------------------------------------------------------- /src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreatedAt, Model, Table, UpdatedAt, PrimaryKey, AutoIncrement, DataType, BelongsToMany } from 'sequelize-typescript'; 2 | import Project, { DbToStdModal_Project, StdToDbModal_Project } from './Project'; 3 | import * as faker from 'faker' 4 | 5 | import UserProject from "./UserProject"; 6 | import { IUser, IProject } from './types'; 7 | 8 | @Table 9 | export default class User extends Model { 10 | @PrimaryKey 11 | @AutoIncrement 12 | @Column(DataType.INTEGER) 13 | declare id: number; 14 | 15 | @Column 16 | name!: string; 17 | 18 | @Column 19 | email?: string; 20 | 21 | @Column 22 | discordId!: string; 23 | 24 | @BelongsToMany(() => Project, () => UserProject) 25 | projects?: Project[]; 26 | 27 | @CreatedAt 28 | @Column 29 | declare createdAt: Date; 30 | 31 | @UpdatedAt 32 | @Column 33 | declare updatedAt: Date; 34 | } 35 | 36 | export function DbToStdModal_User(user: User): IUser { 37 | const stdUser: IUser = { 38 | discordId: user.discordId, 39 | name: user.name, 40 | id: user.id, 41 | email: user.email 42 | }; 43 | return (stdUser); 44 | } 45 | 46 | /** @summary This converts the data model ONLY, and does not represent the actual data in the database */ 47 | export async function StdToDbModal_User(user: IUser): Promise { 48 | const dbUser: any = { 49 | id: user.id, 50 | name: user.name, 51 | email: user.email, 52 | discordId: user.discordId, 53 | }; 54 | return (dbUser as User); 55 | } 56 | 57 | export async function getUserByDiscordId(discordId: string): Promise { 58 | return await User.findOne({ 59 | where: { discordId: discordId } 60 | }); 61 | } 62 | 63 | export function GenerateMockUser(): User { 64 | return new User({ 65 | name: faker.internet.userName(), 66 | email: faker.internet.email(), 67 | discordId: faker.random.alphaNumeric() 68 | }) 69 | } -------------------------------------------------------------------------------- /src/api/user/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { genericServerError, validateAuthenticationHeader } from "../../common/helpers/generic"; 3 | import { getUserByDiscordId } from "../../models/User"; 4 | import { getProjectsByDiscordId, RefreshProjectCache } from "../../models/Project"; 5 | import { GetDiscordIdFromToken } from "../../common/helpers/discord"; 6 | import { BuildResponse, HttpStatus } from "../../common/helpers/responseHelper"; 7 | 8 | module.exports = async (req: Request, res: Response) => { 9 | const authAccess = validateAuthenticationHeader(req, res); 10 | if (!authAccess) return; 11 | 12 | let discordId = await GetDiscordIdFromToken(authAccess, res); 13 | if (!discordId) return; 14 | 15 | deleteUser(discordId) 16 | .then(success => { 17 | if (success) { 18 | BuildResponse(res, HttpStatus.Success, "Success"); 19 | RefreshProjectCache(); 20 | } else { 21 | BuildResponse(res, HttpStatus.NotFound, "User does not exist in database"); 22 | } 23 | }) 24 | .catch((err) => genericServerError(err, res)); 25 | }; 26 | 27 | /** 28 | * @returns True if successful, false if user not found 29 | * @param user User to delete 30 | */ 31 | function deleteUser(discordId: string): Promise { 32 | return new Promise(async (resolve, reject) => { 33 | // Find the projects 34 | const projects = await getProjectsByDiscordId(discordId).catch(reject); 35 | 36 | if (!projects) return; 37 | 38 | // Delete all associated projects with this user 39 | for (let project of projects) { 40 | await project.destroy().catch(reject); 41 | } 42 | 43 | // Find the user 44 | const userOnDb = await getUserByDiscordId(discordId).catch(reject); 45 | if (!userOnDb) { resolve(false); return; } 46 | 47 | // Delete the user 48 | await userOnDb.destroy().catch(reject); 49 | resolve(true); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uwpcommunity.github.io", 3 | "version": "1.0.0", 4 | "description": "Backend for the UWP Community website", 5 | "main": "./build/index.js", 6 | "engines": { 7 | "node": "16.19.1" 8 | }, 9 | "dependencies": { 10 | "@sendgrid/mail": "^6.4.0", 11 | "@types/bluebird": "^3.5.38", 12 | "@types/express": "^4.17.2", 13 | "@types/faker": "^4.1.7", 14 | "@types/glob": "^7.1.1", 15 | "@types/node": "^12.12.7", 16 | "@types/pg": "^7.11.2", 17 | "@types/request": "^2.48.3", 18 | "@types/swagger-jsdoc": "^3.0.2", 19 | "@types/validator": "^10.11.3", 20 | "@types/websocket": "1.0.0", 21 | "body-parser": "^1.19.0", 22 | "cors": "^2.8.5", 23 | "discord.js": "^14.7.1", 24 | "dotenv": "^8.2.0", 25 | "ejs": "^2.7.2", 26 | "express": "^4.17.1", 27 | "express-ws": "^4.0.0", 28 | "faker": "^4.1.0", 29 | "fs-extra": "^8.1.0", 30 | "glob": "^7.1.6", 31 | "js-yaml": "^3.13.1", 32 | "jsonwebtoken": "^8.5.1", 33 | "node-fetch": "^2.6.1", 34 | "pg": "^7.12.1", 35 | "reflect-metadata": "^0.1.13", 36 | "request": "^2.88.0", 37 | "sequelize": "^5.21.5", 38 | "sequelize-typescript": "^1.1.0", 39 | "sqlite3": "^4.1.0", 40 | "swagger-ui-express": "^4.3.0", 41 | "websocket": "^1.0.30" 42 | }, 43 | "devDependencies": { 44 | "@types/cors": "^2.8.6", 45 | "@types/node-fetch": "^2.5.3", 46 | "typescript": "^4.9.5" 47 | }, 48 | "scripts": { 49 | "start": "npm run build && node -r dotenv/config ./build/index.js", 50 | "build": "npm run prebuild && tsc", 51 | "prebuild": "node tasks/cleanup.js" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "git+https://github.com/UWPCommunity/uwpcommunity.github.io.git" 56 | }, 57 | "author": "", 58 | "license": "ISC", 59 | "bugs": { 60 | "url": "https://github.com/UWPCommunity/uwpcommunity.github.io/issues" 61 | }, 62 | "homepage": "https://github.com/UWPCommunity/uwpcommunity.github.io/tree/deploy/backend" 63 | } 64 | -------------------------------------------------------------------------------- /src/api/projects/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import Project, { DbToStdModal_Project, getAllProjects } from "../../models/Project"; 3 | import { IProject } from "../../models/types"; 4 | import { validateAuthenticationHeader } from "../../common/helpers/generic"; 5 | import { GetDiscordIdFromToken, GetGuildUser } from "../../common/helpers/discord"; 6 | import { HttpStatus, BuildResponse, ResponsePromiseReject, IRequestPromiseReject } from "../../common/helpers/responseHelper"; 7 | 8 | module.exports = async (req: Request, res: Response) => { 9 | const reqQuery = req.query as IGetProjectsRequestQuery; 10 | 11 | var isMod = await checkIsMod(req, res); 12 | const projects = await getAllProjectsApi(reqQuery.all && isMod).catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 13 | if (projects) { 14 | BuildResponse(res, HttpStatus.Success, projects); 15 | } 16 | }; 17 | 18 | async function checkIsMod(req: Request, res: Response): Promise { 19 | const authAccess = validateAuthenticationHeader(req, res, false); 20 | if (authAccess) { 21 | const discordId = await GetDiscordIdFromToken(authAccess, res, false); 22 | if (!discordId) return false; 23 | 24 | const user = await GetGuildUser(discordId); 25 | if (!user) return false; 26 | return [...user.roles.cache.values()].filter(role => role.name == "Mod" || role.name == "Admin").length > 0; 27 | } 28 | return false; 29 | } 30 | 31 | export async function getAllProjectsApi(all?: boolean): Promise { 32 | 33 | let allProjects = await getAllProjects(undefined, true).catch(err => ResponsePromiseReject("Internal server error: " + err, HttpStatus.InternalServerError, Promise.reject)); 34 | 35 | if (all !== true) 36 | allProjects = (allProjects as IProject[]).filter(x => x.needsManualReview == false && x.isPrivate == false); 37 | 38 | return allProjects; 39 | } 40 | 41 | interface IGetProjectsRequestQuery { 42 | /** @summary Only useable if user is a mod or admin */ 43 | all?: boolean; 44 | } 45 | -------------------------------------------------------------------------------- /src/api/projects/verification/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { match, genericServerError } from "../../../common/helpers/generic"; 3 | import fetch from "node-fetch"; 4 | import { verificationStorage } from "./verificationStore"; 5 | import sgMail from "@sendgrid/mail"; 6 | 7 | const api_key = process.env.SENDGRID_API_KEY; 8 | 9 | if (!api_key) { 10 | console.error("Missing SENDGRID_API_KEY environment variable. Email verification will not work"); 11 | } else { 12 | sgMail.setApiKey(api_key); 13 | } 14 | 15 | module.exports = async (req: Request, res: Response) => { 16 | const checkedQuery = checkQuery(req.query); 17 | if (typeof checkedQuery == "string") { 18 | res.status(422).send({ 19 | error: "Malformed request", 20 | reason: `Query string "${checkedQuery}" not provided or malformed` 21 | }); 22 | return; 23 | } 24 | 25 | for (let store of verificationStorage) { 26 | if (store.storeId == checkedQuery.storeId && store.code == checkedQuery.code) { 27 | var index = verificationStorage.indexOf(store); 28 | 29 | if (index > -1) { 30 | verificationStorage.splice(index, 1); 31 | } else { 32 | genericServerError("Error occured while cleaning up data for this project", res); 33 | return; 34 | } 35 | res.status(200).end(); 36 | return; 37 | } 38 | } 39 | 40 | res.status(400).send({ 41 | error: "Invalid Request", 42 | reason: `Invalid storeId or code` 43 | }); 44 | }; 45 | 46 | 47 | function checkQuery(query: IGetProjectsVerificationRequestQuery): IGetProjectsVerificationRequestQuery | string { 48 | if (!query.storeId) return "storeId"; 49 | if (!query.code) return "code"; 50 | 51 | return query; 52 | } 53 | interface IGetProjectsVerificationRequestQuery { 54 | /** @summary The store ID of a public app */ 55 | storeId: string; 56 | /** @summary Verification code to check for */ 57 | code: number; 58 | } -------------------------------------------------------------------------------- /src/common/sequalize.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize-typescript'; 2 | import Project from '../models/Project'; 3 | import User, { GenerateMockUser } from '../models/User'; 4 | import UserProject from '../models/UserProject'; 5 | import Role from '../models/Role'; 6 | import * as helpers from './helpers/generic'; 7 | import ProjectImage from '../models/ProjectImage'; 8 | import Tag from '../models/Tag'; 9 | import ProjectTag from '../models/ProjectTag'; 10 | import ProjectFeature from '../models/ProjectFeature'; 11 | 12 | const db_url = process.env.DATABASE_URL; 13 | 14 | if (!db_url) throw new Error(`The environment variable "DATABASE_URL" is missing. Unable to initialize Sequelize database`); 15 | 16 | const dialect = db_url.includes('sqlite') ? 'sqlite' : 'postgres' 17 | 18 | export const sequelize = new Sequelize(db_url, { 19 | dialect, 20 | logging: false, 21 | protocol: dialect, 22 | dialectOptions: { 23 | ssl: true 24 | }, 25 | models: [ProjectImage, ProjectFeature, Project, User, Role, UserProject, ProjectTag, Tag] 26 | }); 27 | 28 | export async function InitDb() { 29 | await sequelize 30 | .authenticate() 31 | .catch((err: string) => { 32 | throw new Error('Unable to connect to the database: ' + err); // Throwing prevents the rest of the code below from running 33 | }); 34 | 35 | if (helpers.DEVENV) { 36 | 37 | await sequelize.sync().catch(console.error); 38 | 39 | Role.count() 40 | .then((c) => { 41 | if (c < 1) { 42 | Role.bulkCreate([ 43 | { name: "Developer" }, 44 | { name: "Beta tester" }, 45 | { name: "Translator" }, 46 | { name: "Other" } 47 | ]); 48 | } 49 | }) 50 | .catch(console.error); 51 | 52 | } 53 | } 54 | 55 | export async function CreateMocks() { 56 | /* const fakeUser = await GenerateMockUser().save() 57 | const launches = await Launch.findAll() 58 | 59 | for (const launch of launches) { 60 | await Promise.all(Array(5).fill(undefined).map( 61 | async () => (await GenerateMockProject(launch, fakeUser)).save() 62 | )) 63 | } */ 64 | } -------------------------------------------------------------------------------- /src/api/projects/tags/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { DbToStdModal_Project, getAllDbProjects } from "../../../models/Project"; 3 | import ProjectTag from "../../../models/ProjectTag"; 4 | import Tag from "../../../models/Tag"; 5 | import { ITag } from "../../../models/types"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | const reqQuery = req.query as IGetProjectTagRequestQuery; 9 | 10 | const queryValidation = checkQuery(reqQuery); 11 | if (queryValidation !== true) { 12 | res.send(queryValidation); 13 | return; 14 | } 15 | 16 | if (reqQuery.projectId !== undefined) { 17 | var tags = await GetByProjectId(reqQuery.projectId); 18 | res.json(tags); 19 | return; 20 | } 21 | 22 | const dbProjects = await getAllDbProjects(); 23 | 24 | var filteredDbProjects = dbProjects.filter(x => (x.tags?.filter(x => x.id == reqQuery.tagId || x.name == reqQuery.tagName).length ?? 0 > 0) && !x.isPrivate); 25 | var filteredProjects = filteredDbProjects.map(DbToStdModal_Project); 26 | 27 | res.json(filteredProjects); 28 | }; 29 | 30 | 31 | async function GetByProjectId(projectId: number): Promise { 32 | const projectTags = await ProjectTag.findAll({ where: { projectId: projectId } }); 33 | if (!projectTags || projectTags.length == 0) { 34 | return []; 35 | } 36 | 37 | var returnData : ITag[] = []; 38 | 39 | for (var projectTag of projectTags) { 40 | var tag = await Tag.findAll({ where: { id: projectTag.tagId }}); 41 | 42 | returnData.push({name: tag[0].name, id: tag[0].id}); 43 | } 44 | 45 | return returnData; 46 | } 47 | 48 | function checkQuery(query: IGetProjectTagRequestQuery): true | string { 49 | if (query.tagId && query.tagName) 50 | return "Only one of 'tagId' or 'tagName' should be specified."; 51 | 52 | if (query.tagId && query.projectId) 53 | return "Only one of 'tagId' or 'projectId' should be specified."; 54 | 55 | if (query.tagName && query.projectId) 56 | return "Only one of 'tagName' or 'projectId' should be specified."; 57 | 58 | if (query.tagId == undefined && query.tagName == undefined && query.projectId == undefined) 59 | return "Either 'tagId', 'tagName' or 'projectId' should be specified."; 60 | 61 | return true; 62 | } 63 | 64 | 65 | interface IGetProjectTagRequestQuery { 66 | tagId?: number; 67 | tagName?: string; 68 | projectId?: number; 69 | } -------------------------------------------------------------------------------- /src/api/bot/user/{discordId}/roles/put.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express-serve-static-core"; 2 | import { GetGuildUser, GetRoles, GetDiscordUser, GetUser } from "../../../../../common/helpers/discord"; 3 | import { DiscordAPIError, Role } from "discord.js"; 4 | import { genericServerError, validateAuthenticationHeader } from "../../../../../common/helpers/generic"; 5 | import { BuildResponse, HttpStatus } from "../../../../../common/helpers/responseHelper"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | const authAccess = validateAuthenticationHeader(req, res); 9 | if (!authAccess) return; 10 | 11 | const user = await GetDiscordUser(authAccess).catch((err) => genericServerError(err, res)); 12 | if (!user) { 13 | BuildResponse(res, HttpStatus.Unauthorized, "Invalid accessToken"); 14 | return; 15 | } 16 | 17 | const guildMember = await GetGuildUser(user.id); 18 | if (!guildMember) { 19 | genericServerError("Unable to get guild member details", res); 20 | return; 21 | } 22 | 23 | 24 | if (req.params['discordId'] !== user.id) { 25 | // If these are mismatched but the user has permission to edit roles, allow it 26 | if (!guildMember.permissions.has(["ManageRoles"])) { 27 | // If the user is a launch coordinator and is try to assign a launch participant, allow it 28 | if (!(guildMember.roles.cache.find(r => r.name == "Launch Coordinator") && req.body.role == "Launch Participant")) { 29 | BuildResponse(res, HttpStatus.Unauthorized, "Authenticated user and requested ID don't match"); 30 | return; 31 | } 32 | } 33 | } 34 | 35 | // Must have a role in the body (JSON) 36 | if (!req.body.role) { 37 | BuildResponse(res, HttpStatus.MalformedRequest, "Missing role in body"); 38 | return; 39 | } 40 | 41 | let guildRoles = await GetRoles(); 42 | if (!guildRoles) { 43 | genericServerError("Unable to get guild roles", res); return; 44 | } 45 | 46 | let roles: Role[] = guildRoles.filter(role => role.name == req.body.role); 47 | if (roles.length == 0) InvalidRole(res); 48 | 49 | switch (req.body.role) { 50 | case "Developer": 51 | case "Launch Participant": 52 | guildMember.roles.add(roles[0]); 53 | BuildResponse(res, HttpStatus.Success, "Success"); 54 | break; 55 | default: 56 | InvalidRole(res); 57 | } 58 | 59 | }; 60 | 61 | function InvalidRole(res: Response) { 62 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid role"); 63 | } -------------------------------------------------------------------------------- /src/api/projects/id/{id}/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User from "../../../../models/User"; 3 | import Project, { DbToStdModal_Project, getAllProjects } from "../../../../models/Project"; 4 | import { IProject } from "../../../../models/types"; 5 | import { genericServerError, validateAuthenticationHeader } from "../../../../common/helpers/generic"; 6 | import { GetDiscordIdFromToken } from "../../../../common/helpers/discord"; 7 | import { HttpStatus, BuildResponse, ResponsePromiseReject, IRequestPromiseReject } from "../../../../common/helpers/responseHelper"; 8 | import UserProject from "../../../../models/UserProject"; 9 | 10 | module.exports = async (req: Request, res: Response) => { 11 | const reqQuery = req.params as IGetProjectRequestQuery; 12 | 13 | // If someone wants the projects for a specific user, they must be authorized 14 | const authAccess = validateAuthenticationHeader(req, res); 15 | if (!authAccess) return; 16 | 17 | const authenticatedDiscordId = await GetDiscordIdFromToken(authAccess, res); 18 | if (authenticatedDiscordId) { 19 | 20 | getProjectById(reqQuery.id as string, res) 21 | .then(result => { 22 | let project: IProject | undefined; 23 | 24 | if (result?.isPrivate ?? false) { 25 | let showPrivate = false; 26 | 27 | result.collaborators?.forEach(collaborator => { 28 | if (collaborator.discordId == authenticatedDiscordId) { 29 | showPrivate = true; 30 | } 31 | }); 32 | 33 | if (showPrivate) { 34 | project = result; 35 | } 36 | 37 | } else { 38 | project = result; 39 | } 40 | 41 | BuildResponse(res, HttpStatus.Success, project); 42 | }) 43 | .catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 44 | } 45 | }; 46 | 47 | export function getProjectById(projectId: string, res: Response): Promise { 48 | return new Promise(async (resolve, reject) => { 49 | 50 | var projects: IProject[] = await getAllProjects().catch(err => ResponsePromiseReject("Internal server error: " + err, HttpStatus.InternalServerError, reject)); 51 | 52 | if (!projects) 53 | return; 54 | 55 | projects = projects.filter(x => x.id.toString() === projectId); 56 | 57 | resolve(projects[0]); 58 | }); 59 | } 60 | 61 | interface IGetProjectRequestQuery { 62 | id?: string; 63 | } -------------------------------------------------------------------------------- /src/api/user/{discordId}/projects/get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User from "../../../../models/User"; 3 | import Project, { DbToStdModal_Project, getAllDbProjects, getProjectsByDiscordId } from "../../../../models/Project"; 4 | import { IProject } from "../../../../models/types"; 5 | import { genericServerError, validateAuthenticationHeader } from "../../../../common/helpers/generic"; 6 | import { GetDiscordIdFromToken } from "../../../../common/helpers/discord"; 7 | import { HttpStatus, BuildResponse } from "../../../../common/helpers/responseHelper"; 8 | 9 | module.exports = async (req: Request, res: Response) => { 10 | let discordId = req.params['discordId']; 11 | 12 | // If someone wants the projects for a specific user, they must be authorized 13 | const authAccess = validateAuthenticationHeader(req, res); 14 | if (!authAccess) return; 15 | 16 | const authenticatedDiscordId = await GetDiscordIdFromToken(authAccess, res); 17 | 18 | if (authenticatedDiscordId) { 19 | // If discordId === authenticatedDiscordId return all projects 20 | // Else return only public projects 21 | const results = (discordId === authenticatedDiscordId) 22 | ? await getAllProjectsbyUser(discordId).catch(err => genericServerError(err, res)) 23 | : await getPublicProjectsbyUser(discordId).catch(err => genericServerError(err, res)); 24 | 25 | if (results) BuildResponse(res, HttpStatus.Success, results); 26 | } 27 | }; 28 | 29 | export function getAllProjectsbyUser(discordId: string): Promise { 30 | return new Promise(async (resolve, reject) => { 31 | 32 | const results = await getProjectsByDiscordId(discordId).catch(reject); 33 | 34 | if (results) { 35 | let projects: IProject[] = []; 36 | 37 | for (let project of results) { 38 | let proj = DbToStdModal_Project(project) 39 | if (proj) projects.push(proj); 40 | } 41 | 42 | resolve(projects); 43 | } 44 | }); 45 | } 46 | 47 | export function getPublicProjectsbyUser(discordId: string): Promise { 48 | return new Promise(async (resolve, reject) => { 49 | 50 | var projects = await getAllDbProjects(); 51 | 52 | var results = projects.filter(x => !x.isPrivate && x.users?.filter(x => x.discordId === discordId).length); 53 | 54 | if (results) { 55 | let projects: IProject[] = []; 56 | 57 | for (let project of results) { 58 | let proj = DbToStdModal_Project(project) 59 | if (proj) projects.push(proj); 60 | } 61 | 62 | resolve(projects); 63 | } 64 | }); 65 | } 66 | 67 | interface IGetProjectsRequestQuery { 68 | discordId?: string; 69 | } 70 | -------------------------------------------------------------------------------- /src/api/projects/verification/post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { match } from "../../../common/helpers/generic"; 3 | import fetch from "node-fetch"; 4 | import { verificationStorage } from "./verificationStore"; 5 | import sgMail from "@sendgrid/mail"; 6 | 7 | const api_key = process.env.SENDGRID_API_KEY; 8 | 9 | if (!api_key) { 10 | console.error("Missing SENDGRID_API_KEY environment variable. Email verification will not work"); 11 | } else { 12 | sgMail.setApiKey(api_key); 13 | } 14 | 15 | module.exports = async (req: Request, res: Response) => { 16 | const checkedBody = checkBody(req.body); 17 | if (typeof checkedBody == "string") { 18 | res.status(422).send({ 19 | error: "Malformed request", 20 | reason: `Query string "${checkedBody}" not provided or malformed` 21 | }); 22 | return; 23 | } 24 | 25 | const supportEmail = await getSupportEmail(checkedBody.storeId, res); 26 | if (!supportEmail) return; 27 | 28 | // Random six digit code 29 | const verificationCode = Math.floor(Math.random() * (999999 - 111111) + 111111); 30 | 31 | verificationStorage.push({ 32 | storeId: checkedBody.storeId, 33 | code: verificationCode 34 | }); 35 | sendVerificationEmail(supportEmail, verificationCode); 36 | res.status(200).end(); 37 | }; 38 | 39 | async function sendVerificationEmail(emailAddress: string, code: number) { 40 | console.log(`Sending email to ${emailAddress}`); 41 | const msg = { 42 | to: emailAddress, 43 | from: 'noreply@uwpcommunity.github.io', 44 | subject: 'Project Verification - UWP Community', 45 | text: 'Your project verification code is ' + code 46 | }; 47 | await sgMail.send(msg); 48 | } 49 | 50 | async function getSupportEmail(storeId: string, res: Response): Promise { 51 | const initialScrape = await fetch(`https://www.microsoft.com/store/apps/${storeId}`); 52 | const storeScrapeResult = await initialScrape.text(); 53 | 54 | if (storeScrapeResult.includes(`the page you requested cannot be found`)) { 55 | res.status(422).send({ 56 | error: "Malformed request", 57 | reason: "Invalid storeId" 58 | }); 59 | return; 60 | } 61 | 62 | const supportEmail = match(storeScrapeResult, /.* support<\/a>/); 63 | 64 | if (!supportEmail) { 65 | res.status(404).send({ 66 | error: "Not found", 67 | reason: "Support email not present" 68 | }); 69 | return; 70 | } 71 | 72 | return supportEmail; 73 | } 74 | 75 | 76 | function checkBody(query: IPostProjectsVerificationRequestBody): IPostProjectsVerificationRequestBody | string { 77 | if (!query.storeId) return "storeId"; 78 | 79 | return query; 80 | } 81 | interface IPostProjectsVerificationRequestBody { 82 | /** @summary The store ID of a public app */ 83 | storeId: string; 84 | } -------------------------------------------------------------------------------- /docs/DbSchema.drawio: -------------------------------------------------------------------------------- 1 | 7V1bc5s6EP41fkzH3Hx5jC9Jc5q0aZI2bV86slGMGow8Qmns/vojjGQbBDa2uc6o7XSsRQghfftpd7VAyxjOl9cELJw7bEO3pbftZcsYtXS92+mx/wPBKhSYFhfMCLJDkbYVPKJ/kAvbXPqGbOhHKlKMXYoWUeEUex6c0ogMEILfo9VesBu96gLMoCR4nAJXlj4jmzqhtGe1t/KPEM0ccWWtzY/MgajMBb4DbPy+IzLGLWNIMKbhr/lyCN1g7MS4hOddpRzddIxAj2Y54e673RtPtPaf5SvsX08+3XwhPy403sxf4L7xO/7mQ+L/vif4DxtPn/edrsSA+O9o7gKPlQYv2KOP/EiblacOcu1bsMJvQYd8CqavojRwMEH/WH3gskMaE7DDhPL51jtBa8h1h9jFZH0dA7aDv5EzH4MW+bUI9Nm59+LutZjoDiwjFW+BT0UvseuChY8m634HJ84BmSFvgCnFc15J3OVVtFMv6z/sOHDRzGOyKbsWJGIswrvRTFaWZ0eMNCQULndEfLauIZ5DSlasCj+q9zhyhOrw4vsWh1qHy5xdDBomxz/H/mzT9BYe7AdHyDFo0SW03NgyQhywCH6ym6cIuA8MRsCbBUcHFC/4ALvwRUwI4V0Pfk/EJGg7gxzWHQQjh5hmXnLxHNn2utVd5Hh4DU1/AabIm92GVzHMreiBXy0QYdbki7tWSoc1Br2gN5gCCiYbUC8w8uh6IK0B+8fGe9j+YLUsdqdDVta2ZfYvqE7oEHs+JQCtZx4y5L3DAH0Dm+DFE8MaFHe7q0BWDER6Ioj2K/FhaHEoMYXLBCWjXRSSDAlJ95/ORtJkV4UTUJEVUFH05AaeQ/gIbpND/1Qk6ClI2CyQvHORNSiCkB1IGGUiQpBWbCUqil/ah+Gwbqzh5FIGmVgZyUSQTv7QsSToXFVOJiF6GsskG3UsjEnygMOz80DeOvR1NvHv0IXdMT5/frrQOvLaElqzikxqTyY9vTwySUZPV5HJGWSyXyNrTSaJCNZll+cBu1AxSe2ZRGv3KrZLdNnJUVRypl2y0ccGUolspq5jbTIiVIitVU6IzaxbhE2XbVcVYas+wrZR3eZE2HTZjlURthzWn8JN2eIQ0ZMQ8RnMoTJkSzVkj6eSyuNrel8CTtVE0nQrtpcCgvpbsR3Zih3PAWJtXCkmqTmTlBlcSwaPbN4qJjmLSTppIGgAk8gW6gj5U0xsFV2rPZVUH13ryOas4pLzuKSbgoL6c4khg0FlsFUeXrP00+JrelcvCieyK6Pia9XH14w0f6i+8TWBZRVfyzfvpJ+ChPrH10w5l/pysVAhttKt2ePZpPIQm6lL2KmaSxpuzJpp1kr9jdmuTCQj6E8JWlCEPUUmNSeTyqNsXUUmOZNJt8FkIich3fj3BP0FVNkldaeS6qNsXfmxDMUl53GJkYKCWnFJYh6vTCXjJYXEA+4t8l7VDmADCEVkrlWWX6/4JOfk+ubSiZxJcI3ox7eJIpOGkEknazy/KASpdIKcyaQR2QTJzxhJUBjhd8/FwF7TieKSmnNJr8QQbCKAVNAk76f+mssl8t6ehAUXhWpMCX7dvBpIO2me47t2T2ssXWgyS8iTbyRMtAsm0L3HPlqHi40R564jAVDeVq54a8Uh1e+dP92T4a/vcw0vvl0Ovpqj/74+P5FBwtIxBBTOMFmpRLR8F44ELsgIqHT06CUaoYnwkRcO9ZTn/qUj85IQhUe68tZn6ZBzWm/Bmzd1GJMoj7b2VqhuVm2FynmMikzOtEObm9VqymwSvHxCxoPKaG1V8sC4kTUCttnIyx8jMmOojNbqM1rNNNapcUarnButMlpzSEQr/FnPwhBhyXEQlc5auhl7PJVUns5qySGVqomkOUbsflWstRXb+9U2xtc/0G9zdvv1I105z//MhPDajf/l3WMmmuKRevNImZmsidBRezKn08heXaw1iyQTYIa9fmjPoLDd2TA6eIY94I63UmnYHDoXzi/07MvggxGsOH5gWHjCd8BbCa94e2gOPPvLGipsqMnqx+Z0VvgZTOsHSxRHwuENSytRWiK6OY393jmLlbYnBQVxTqrv6uM3MuX3n7yZxceJCgpJq8jpIRjEveSwywbtRF+XCwl0AUV/YaS/ScDg17gPlGHra3fi3z+w+tEmwlvnZ23hdbChbqydcGSkdtiUg9VONa6rqf3tSk+7anu7Fa+v9/sxLQl7sNWZzSScoUZySKlyNTpBHTaq1z5O9TKp0d4Xf+2q0d43Z9dGjzbf0tkEuWP+YlY9MmINafGGUhQpN+xmeA9VrsgE3tTBZH0/I0TY6hjupweNTVhrYBuuPKwyAuXtvEl/72O0tQGh2U9+FcCxGDTN0zB4LJmbsTWjt5/L49VFt/Li8uSVXfasws1GSSvUBkFVr7zIvEMgfK38k5lkJ0ptEJS6QbBfeWu5QZDcZfXRpkLyJPUUJNRqgyC56/LTPD8hUIG9wgJ7OVFJmRsEyV2Wn/2pmkiaE9nbr4q1Du0ldz3Dm/xq79fVO3ohNqEPRi/E0NfGcbSMqEHdj7WQ2XG0Yh6aVYzjKH9TYr/nGK+v8fdaptaPe5rR+md7molJtrLhJzLkZdZWrmZ2V/NAPvxx3wcu1dVMhIlsDSpPs3BP8wCG0vW5Fo5mYvdk41D5maU+VGGeZCAWhgd561eloZX8aNYJNFKmk5nYvQxbncrHLIhBOicxSGFQyOBhVpU8cqQPeHrySMylO+hI7jPxdv3IEx/LKzZ3JL7dqJ3oNsYbMsTHxgvebzzkNsbr6/y1Yie6gaxIMKa71QlYOHfYhkGN/wE= -------------------------------------------------------------------------------- /src/bot/commands/portal.ts: -------------------------------------------------------------------------------- 1 | import { IBotCommandArgument } from "../../models/types"; 2 | import { Message, TextChannel, Embed, EmbedBuilder } from "discord.js"; 3 | 4 | interface portalImage { 5 | in: string; 6 | out: string; 7 | } 8 | 9 | var portalImages: portalImage[] = [ 10 | { 11 | out: "https://cdn.discordapp.com/attachments/642818541426573344/960339465891631186/b.png", 12 | in: "https://cdn.discordapp.com/attachments/642818541426573344/960339466185224212/o.png", 13 | }, 14 | ] 15 | 16 | 17 | export default async (message: Message, commandParts: string[], args: IBotCommandArgument[]) => { 18 | var channelMentions = [...message.mentions.channels.values()]; 19 | if (channelMentions.length > 1) { 20 | (message.channel as TextChannel).send("I can only create a portal to one channel at a time"); 21 | return; 22 | } 23 | 24 | if (channelMentions.length == 0) { 25 | (message.channel as TextChannel).send("Where to? Try again with a channel mention!"); 26 | return; 27 | } 28 | 29 | var channel = channelMentions[0]; 30 | if (!channel) 31 | return; 32 | 33 | if (channel.id == message.channel.id) { 34 | (message.channel as TextChannel).send("You're already in that channel!"); 35 | return; 36 | } 37 | 38 | if (!(channel as TextChannel).permissionsFor(message.author.id)?.has(["SendMessages"]) || 39 | !(channel as TextChannel).permissionsFor(message.author.id)?.has(["ViewChannel"])) { 40 | (message.channel as TextChannel).send(`You aren't allowed to open a portal there`); 41 | return; 42 | } 43 | 44 | var portalImage = portalImages[Math.floor(Math.random() * portalImages.length)]; 45 | 46 | var inMessage = await (message.channel as TextChannel).send({ embeds: [createInitialInEmbed(channel as TextChannel, portalImage.in).data] }); 47 | var outMessage = await (channel as TextChannel).send({ embeds: [createOutputEmbed(message.author.id, message.channel as TextChannel, portalImage.out, inMessage.url).data] }); 48 | 49 | await inMessage.edit({embeds: [createFinalInEmbed(channel as TextChannel, portalImage.in, outMessage.url).data]}); 50 | } 51 | 52 | function createInitialInEmbed(channel: TextChannel, img: string): EmbedBuilder { 53 | return new EmbedBuilder() 54 | .setThumbnail(img) 55 | .setDescription(`Spawning a portal to <#${channel.id}>...`); 56 | } 57 | 58 | function createOutputEmbed(userId: string, channel: TextChannel, img: string, msgLink: string): EmbedBuilder { 59 | return new EmbedBuilder() 60 | .setThumbnail(img) 61 | .setDescription(`<@${userId}> opened a portal from <#${channel.id}>!\n[Go back through the portal](${msgLink})`); 62 | } 63 | 64 | function createFinalInEmbed(channel: TextChannel, img: string, msgLink: string): EmbedBuilder { 65 | return new EmbedBuilder() 66 | .setThumbnail(img) 67 | .setDescription(`A portal to <#${channel.id}> was opened!\n[Enter the portal](${msgLink})`); 68 | } -------------------------------------------------------------------------------- /src/models/UserProject.ts: -------------------------------------------------------------------------------- 1 | import { Column, Model, Table, ForeignKey, PrimaryKey, AutoIncrement, DataType, BelongsTo, CreatedAt } from 'sequelize-typescript'; 2 | import User from './User'; 3 | import Project from './Project'; 4 | import Role from './Role'; 5 | import { IProjectCollaborator } from './types'; 6 | 7 | @Table 8 | export default class UserProject extends Model { 9 | @PrimaryKey 10 | @AutoIncrement 11 | @Column(DataType.INTEGER) 12 | declare id: number; 13 | 14 | @Column 15 | isOwner!: boolean; 16 | 17 | @BelongsTo(() => User, 'userId') 18 | user!: User; 19 | 20 | @ForeignKey(() => User) 21 | @Column 22 | userId!: number; 23 | 24 | @BelongsTo(() => Project, 'projectId') 25 | project!: Project; 26 | 27 | @ForeignKey(() => Project) 28 | @Column 29 | projectId!: number; 30 | 31 | @BelongsTo(() => Role, 'roleId') 32 | role!: Role; 33 | 34 | @ForeignKey(() => Role) 35 | @Column 36 | roleId!: number; 37 | } 38 | 39 | export function DbToStdModal_UserProject(userProject: UserProject): IProjectCollaborator | undefined { 40 | if(!userProject.user) { 41 | return undefined; 42 | } 43 | 44 | let user: IProjectCollaborator = 45 | { 46 | id: userProject.userId, 47 | isOwner: userProject.isOwner, 48 | role: userProject.role?.name ?? "Other", 49 | name: userProject.user?.name, 50 | discordId: userProject.user?.discordId, 51 | }; 52 | 53 | return user; 54 | } 55 | 56 | export async function GetUsersByProjectId(ProjectId: number) { 57 | const RelevantUserProjects = await UserProject.findAll({ where: { projectId: ProjectId } }); 58 | 59 | let users: User[] = []; 60 | for (let user of RelevantUserProjects) { 61 | const RelevantUser = await User.findOne({ where: { id: user.id } }); 62 | if (RelevantUser) users.push(RelevantUser); 63 | } 64 | 65 | return users; 66 | } 67 | 68 | export async function GetProjectCollaborators(ProjectId: number): Promise { 69 | const RelevantUserProjects = await UserProject.findAll({ where: { projectId: ProjectId }, include: [{ model: User }, {model: Role}] }); 70 | 71 | let users: IProjectCollaborator[] = RelevantUserProjects.map(DbToStdModal_UserProject).filter(x=> x != undefined) as IProjectCollaborator[];; 72 | 73 | return users; 74 | } 75 | 76 | export async function UserOwnsProject(user: User, project: Project): Promise { 77 | const OwnedUserProject = await UserProject.findAll({ where: { isOwner: true, userId: user.id, projectId: project.id } }); 78 | if (OwnedUserProject.length > 0) return true; 79 | return false; 80 | } 81 | 82 | export async function GetProjectsByUserId(UserId: number, isOwner: boolean = false): Promise { 83 | const RelevantUserProjects = await UserProject.findAll({ where: { userId: UserId, isOwner }, include: [{ model: Project }] }); 84 | 85 | return RelevantUserProjects.flatMap(x => x.project); 86 | } -------------------------------------------------------------------------------- /src/bot/commands/role.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel } from "discord.js"; 2 | import { GetGuild, GetRoles } from "../../common/helpers/discord"; 3 | import { capitalizeFirstLetter } from "../../common/helpers/generic"; 4 | import { IBotCommandArgument } from "../../models/types"; 5 | import { getUserByDiscordId } from "../../models/User"; 6 | 7 | export default async (discordMessage: Message, commandParts: string[], args: IBotCommandArgument[]) => { 8 | const command = commandParts[0].toLowerCase(); 9 | 10 | switch (command) { 11 | case "find": 12 | findRole(discordMessage, commandParts, args); 13 | break; 14 | } 15 | }; 16 | 17 | async function findRole(discordMessage: Message, commandParts: string[], args: IBotCommandArgument[]) { 18 | switch (commandParts[1].toLowerCase()) { 19 | case "empty": 20 | findEmptyRoles(discordMessage); 21 | break; 22 | default: 23 | fromSpecificRole(commandParts[1].toLowerCase(), discordMessage, commandParts, args); 24 | } 25 | } 26 | 27 | async function fromSpecificRole(roleName: string, discordMessage: Message, commandParts: string[], args: IBotCommandArgument[]) { 28 | const roles = await GetRoles(); 29 | if (!roles) 30 | return; 31 | 32 | const role = roles.find(i => i.name.toLowerCase() == roleName); 33 | 34 | if (!role) { 35 | (discordMessage.channel as TextChannel).send(`Role not found`); 36 | return; 37 | } 38 | 39 | const numberOfMembers = role.members.size; 40 | const dateRoleCreated = role.createdAt.toUTCString(); 41 | const mentionable = role.mentionable; 42 | 43 | let messageToSend = `__**${capitalizeFirstLetter(roleName)}\ role info**__: 44 | Member count: ${numberOfMembers} 45 | Date created: ${dateRoleCreated} 46 | Mentionable: ${mentionable}` 47 | 48 | messageToSend += `\nMembers:\n===========`; 49 | 50 | for (let memberItem of role.members) { 51 | const member = memberItem[1]; 52 | 53 | messageToSend += `\n${member.user.username}#${member.user.discriminator}` 54 | 55 | if (commandParts.filter(x => x == "detailed").length > 0) { 56 | var user = await getUserByDiscordId(member.id); 57 | 58 | if(user) { 59 | messageToSend += ` | ${user.name}`; 60 | } 61 | 62 | if (user && user.email) { 63 | messageToSend += ` | ${user.email}` 64 | } 65 | } 66 | } 67 | 68 | await (discordMessage.channel as TextChannel).send(messageToSend); 69 | 70 | } 71 | 72 | async function findEmptyRoles(discordMessage: Message) { 73 | const roles = await GetRoles(); 74 | if (!roles) 75 | return; 76 | 77 | const emptyRoles = roles.filter(x => x.members.size == 0); 78 | 79 | let message = `Total empty roles: ${emptyRoles.length}`; 80 | 81 | if (emptyRoles.length > 0) 82 | message += "\n"; 83 | 84 | for (const role of emptyRoles) { 85 | message += `\n${role.name}` 86 | } 87 | 88 | await (discordMessage.channel as TextChannel).send(message); 89 | } -------------------------------------------------------------------------------- /src/api/bot/project/roles/post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express-serve-static-core"; 2 | import { GetGuildUser, GetGuild, GetDiscordUser, GetRoles } from "../../../../common/helpers/discord"; 3 | import { genericServerError, validateAuthenticationHeader, capitalizeFirstLetter } from "../../../../common/helpers/generic"; 4 | import { getProjectsByDiscordId } from "../../../../models/Project"; 5 | import { HttpStatus, BuildResponse } from "../../../../common/helpers/responseHelper"; 6 | 7 | module.exports = async (req: Request, res: Response) => { 8 | const authAccess = validateAuthenticationHeader(req, res); 9 | if (!authAccess) return; 10 | 11 | const user = await GetDiscordUser(authAccess).catch((err) => genericServerError(err, res)); 12 | if (!user) { 13 | BuildResponse(res, HttpStatus.Unauthorized, "Invalid accessToken"); 14 | return; 15 | } 16 | 17 | const guildMember = await GetGuildUser(user.id); 18 | if (!guildMember) { 19 | genericServerError("Unable to get guild details", res); 20 | return; 21 | } 22 | 23 | const body = checkBody(req.body); 24 | if (typeof body == "string") { 25 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${body}" not provided or malformed`); 26 | return; 27 | } 28 | 29 | // If trying to create a role for a project, make sure the project exists 30 | let Projects = await getProjectsByDiscordId(user.id); 31 | if (Projects.filter(project => req.body.appName == project.appName).length == 0) { 32 | BuildResponse(res, HttpStatus.MalformedRequest, "The project doesn't exist"); 33 | return; 34 | } 35 | 36 | if (allowedProjectSubRoles.filter(subRole => req.body.subRole == subRole).length == 0) { 37 | BuildResponse(res, HttpStatus.MalformedRequest, `Invalid project subRole. Allowed values are: "${allowedProjectSubRoles.join(`" , "`)}"`); 38 | return; 39 | } 40 | 41 | const server = await GetGuild(); 42 | if (!server) return; 43 | 44 | const roleName = req.body.appName + " " + capitalizeFirstLetter(req.body.subRole); 45 | 46 | const serverRoles = await GetRoles(); 47 | if (!serverRoles) 48 | return; 49 | 50 | // Check that the role doesn't already exist 51 | if (serverRoles.filter(role => role.name == roleName).length > 0) { 52 | BuildResponse(res, HttpStatus.Unauthorized, "Role already exists"); 53 | return; 54 | } 55 | 56 | server.roles.create({ 57 | name: roleName, 58 | mentionable: true, 59 | color: req.body.color 60 | }); 61 | 62 | BuildResponse(res, HttpStatus.Success, "Success"); 63 | }; 64 | 65 | const allowedProjectSubRoles = ["translator", "dev", "beta tester"]; 66 | 67 | interface IPostProjectRoles { 68 | appName: "Cortana", 69 | subRole: "dev" | "beta tester" | "translator", 70 | color: string; 71 | } 72 | 73 | function checkBody(body: IPostProjectRoles): IPostProjectRoles | string { 74 | if (!body.appName) return "appName"; 75 | if (!allowedProjectSubRoles.includes(body.subRole)) return "subRole"; 76 | 77 | return body; 78 | } -------------------------------------------------------------------------------- /src/models/types.ts: -------------------------------------------------------------------------------- 1 | export interface IProject { 2 | id: number; 3 | 4 | appName: string; 5 | description: string; 6 | isPrivate: boolean; 7 | downloadLink?: string; 8 | githubLink?: string; 9 | externalLink?: string; 10 | 11 | heroImage: string; 12 | images: string[]; 13 | appIcon?: string; 14 | accentColor?: string; 15 | 16 | awaitingLaunchApproval: boolean; 17 | needsManualReview: boolean; 18 | 19 | lookingForRoles?: string[]; 20 | 21 | collaborators: IProjectCollaborator[]; 22 | tags: ITag[]; 23 | 24 | features?: string[]; 25 | 26 | createdAt: Date; 27 | updatedAt: Date; 28 | category?: string; 29 | }; 30 | 31 | export interface ITag { 32 | id: number; 33 | projects?: IProject[]; 34 | name: string; 35 | icon?: string; 36 | } 37 | 38 | export interface IProjectCollaborator extends IUser { 39 | isOwner: boolean; 40 | role: "Developer" | "Translator" | "Beta Tester" | "Other" | "Support" | "Lead" | "Patreon" | "Advocate"; 41 | } 42 | 43 | export interface IProjects { 44 | projects: IProject[], 45 | privateCount: number 46 | } 47 | 48 | export interface IUser { 49 | id?: number; 50 | 51 | name: string; 52 | discordId: string; 53 | email?: string; // This is a contact email supplied by the user, and is safe to be public 54 | } 55 | 56 | export const ResponseErrorReasons = { 57 | MissingAuth: "Missing authorization header", 58 | UserExists: "User already exists", 59 | UserNotExists: "User does not exist", 60 | ProjectExists: "Project already exists", 61 | ProjectNotExist: "Project does not exist", 62 | GenericError: "Internal server error" 63 | } 64 | 65 | /** 66 | * @summary Discord API user object 67 | */ 68 | export interface IDiscordUser { 69 | /** @summary the user's id */ 70 | id: string; 71 | /** @summary the user's username, not unique across the platform */ 72 | username: string; 73 | /** @summary the user's 4-digit discord-tag */ 74 | discriminator: string; 75 | /** @summary the user's avatar hash */ 76 | avatar: string; 77 | /** @summary whether the user belongs to an OAuth2 application */ 78 | bot?: boolean; 79 | /** @summary the user's id */ 80 | mfa_enabled?: boolean; 81 | /** @summary whether the user has two factor enabled on their account */ 82 | locale?: string; 83 | /** @summary the user's chosen language option */ 84 | verified?: string; 85 | /** @summary whether the email on this account has been verified */ 86 | email?: string; 87 | /** @summary the flags on a user's account */ 88 | flags?: number; 89 | /** @summary the type of Nitro subscription on a user's account */ 90 | premium_type?: number; 91 | } 92 | 93 | export interface IDiscordAuthResponse { 94 | "access_token": string; 95 | "token_type": "Bearer" 96 | "expires_in": number, 97 | "refresh_token": string, 98 | "scope": string; 99 | } 100 | 101 | 102 | export interface IBotCommandArgument { 103 | name: string; 104 | value: string; 105 | } -------------------------------------------------------------------------------- /src/bot/events/guildMemberRemove.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember, TextChannel } from "discord.js"; 2 | import { getUserByDiscordId } from "../../models/User"; 3 | import { getOwnedProjectsByDiscordId, nukeProject } from "../../models/Project"; 4 | import { GetChannelByName } from "../../common/helpers/discord"; 5 | 6 | export default async (guildMember: GuildMember) => { 7 | let user = await getUserByDiscordId(guildMember.id); 8 | if (user) { 9 | let removalMessage: string = `Registered user ${guildMember.user.username}#${guildMember.user.discriminator} ${guildMember.nickname ? `(${guildMember.nickname})` : ""} has left the server, information has been deleted from database`; 10 | console.log(removalMessage); 11 | await sendMessageWithBackups(guildMember, removalMessage); 12 | 13 | await removeProjectsFromDb(user.discordId).catch(message => sendMessageWithBackups(guildMember, `Internal error whild removing user projects: ${message}`)); 14 | await removeUserFromDb(user.discordId).catch(message => sendMessageWithBackups(guildMember, `Internal error while removing user: ${message}`)); 15 | } 16 | } 17 | 18 | async function sendMessageWithBackups(guildMember: GuildMember, message: string) { 19 | // Uses a bot channel as primary message vessel, notifies mod-chat as a backup, and then general if that fails, too. 20 | let botChannel = await GetChannelByName("bot-stuff") as TextChannel; 21 | if (botChannel) botChannel.send(message); 22 | else { 23 | let modChannel = await GetChannelByName("mod-chat") as TextChannel; 24 | if (modChannel) modChannel.send("Something went wrong with the bot, please have an Admin take a look."); 25 | else { 26 | let generalChannel = await GetChannelByName("user-chat") as TextChannel; 27 | if (!generalChannel) throw "Couldn't find bot channel, mod channel, or general channel"; 28 | else generalChannel.send("A few things went wrong went wrong with the bot, please have an Admin take a look."); 29 | } 30 | return; 31 | } 32 | } 33 | 34 | /** 35 | * @returns True if successful, false if user not found 36 | * @param user User who's projects are to be deleted 37 | */ 38 | async function removeProjectsFromDb(discordId: string) { 39 | return new Promise(async (resolve, reject) => { 40 | // Find the projects 41 | const projects = await getOwnedProjectsByDiscordId(discordId).catch(reject); 42 | if (!projects) return; 43 | 44 | // Delete all associated projects with this user 45 | for (let project of projects) { 46 | await nukeProject(project.appName, discordId).catch(reject); 47 | } 48 | }); 49 | } 50 | 51 | /** 52 | * @returns True if successful, false if user not found 53 | * @param user User to delete 54 | */ 55 | async function removeUserFromDb(discordId: string) { 56 | return new Promise(async (resolve, reject) => { 57 | 58 | // Find the user 59 | const userOnDb = await getUserByDiscordId(discordId).catch(reject); 60 | if (!userOnDb) { resolve(false); return; } 61 | 62 | // Delete the user 63 | userOnDb.destroy().then(resolve).catch(reject); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/bot/events/messageHandlers/devChatterWarning.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel } from "discord.js"; 2 | import fetch from "node-fetch"; 3 | import { bot, GetChannelByName } from "../../../common/helpers/discord"; 4 | 5 | const disallowedDomainsForApiLookup = ["docs.microsoft.com", "devblogs.microsoft.com", "social.msdn.microsoft.com", "stackoverflow.com"]; 6 | const allowedWords = ["winappsdk"]; 7 | 8 | function hasDisallowedDomain(text: string): boolean { 9 | for (let domain of disallowedDomainsForApiLookup) { 10 | if (text.includes(`href="https://${domain}`)) 11 | return true; 12 | } 13 | 14 | return false; 15 | } 16 | 17 | export async function devChatterWarning(discordMessage: Message) { 18 | var generalChannel = await GetChannelByName("user-chat") as TextChannel; 19 | 20 | if (!generalChannel || discordMessage.channel.id != generalChannel.id) 21 | return; 22 | 23 | if (hasDisallowedDomain(discordMessage.content)) { 24 | await displayWarning(discordMessage); 25 | return; 26 | } 27 | 28 | // Allow links regardless of possible dev talk. 29 | if (discordMessage.content.includes("http")) 30 | return; 31 | 32 | let codeBlockMatch: string[] = discordMessage.content.match(/```[\s\S]+?```/g)?.map(x => x) ?? []; 33 | let interfaceNameMatch: string[] = discordMessage.content.match(/[^:]I[A-Z][a-z]{3,}/g)?.map(x => x) ?? []; 34 | let pascalOrCamelCaseOrCppNamespaceMatch: string[] = discordMessage.content.match(/[^:](?:[A-Z][a-z]{2,}:?:?){3,}/g)?.map(x => x) ?? []; 35 | let snakeCaseMatch: string[] = discordMessage.content.match(/[A-Za-z]{2,}_[A-Za-z]{2,}/g)?.map(x => x) ?? []; 36 | let kebabCaseMatch: string[] = discordMessage.content.match(/(?:[A-Za-z]{3,}-[A-Za-z]{3,}){2,}/g)?.map(x => x) ?? []; 37 | 38 | var allMatches = codeBlockMatch.concat(interfaceNameMatch).concat(pascalOrCamelCaseOrCppNamespaceMatch).concat(snakeCaseMatch).concat(kebabCaseMatch); 39 | 40 | for (let match of allMatches) { 41 | if (allowedWords.includes(match.toLowerCase())) 42 | continue; 43 | 44 | var searchQuery = await fetch(`https://www.bing.com/search?q=${encodeURIComponent(match)}`, { 45 | headers: { 46 | "User-Agent": "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/4.0)" 47 | } 48 | }); 49 | 50 | var response = await searchQuery.text(); 51 | 52 | if (hasDisallowedDomain(response)) { 53 | await displayWarning(discordMessage); 54 | return; 55 | } 56 | } 57 | } 58 | 59 | async function displayWarning(discordMessage: Message) { 60 | var msg = await discordMessage.reply(`To make sure everyone feels welcome to take part in the server, <#372137812037730306> is for non-technical chat only.\nTechnical discussions should take place in <#663434534087426129>, <#677261195321016371> or another appropriate channel.\n\nFor your convenience, use the \`!portal #channelname\` command to seamlessly switch to another channel.`); 61 | 62 | bot.on("messageDelete", async deletedMsg => { 63 | if (deletedMsg.id == discordMessage.id) 64 | await msg.delete(); 65 | }); 66 | } 67 | 68 | function capitalizeFirstLetter(str: string): string { 69 | return str.charAt(0).toUpperCase() + str.slice(1); 70 | } -------------------------------------------------------------------------------- /src/common/helpers/generic.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request } from "express"; 2 | import { HttpStatus, BuildResponse } from "./responseHelper"; 3 | 4 | /** 5 | * @summary Get the first matching regex group, instead of an array with the full string and all matches 6 | * @param {string} toMatch 7 | * @param {regex} regex 8 | * @returns {string} First matching regex group 9 | */ 10 | export function match(toMatch: string, regex: RegExp) { 11 | let m = regex.exec(toMatch); 12 | return (m && m[1]) ? m[1] : undefined; 13 | } 14 | 15 | export function isUrl(toMatch: string) { 16 | // RFC 3986 compliant: https://datatracker.ietf.org/doc/html/rfc3986#appendix-B 17 | const urlRegex = /^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/ig; 18 | 19 | let m = urlRegex.exec(toMatch); 20 | return (m ?? []).filter(x => x != undefined && x.length).length > 0; 21 | } 22 | 23 | export function replaceAll(text: string, target: string, replacement: string) { 24 | return text.split(target).join(replacement); 25 | }; 26 | 27 | export function remove(text: string, target: string) { 28 | return text.split(target).join(""); 29 | }; 30 | 31 | /*** 32 | * @summary Compute the edit distance between two given strings 33 | * @see https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#JavaScript 34 | */ 35 | export function levenshteinDistance(a: string, b: string) { 36 | if (a.length === 0) return b.length; 37 | if (b.length === 0) return a.length; 38 | var matrix = []; 39 | var i; 40 | for (i = 0; i <= b.length; i++) { 41 | matrix[i] = [i]; 42 | } 43 | 44 | var j; 45 | for (j = 0; j <= a.length; j++) { 46 | matrix[0][j] = j; 47 | } 48 | 49 | for (i = 1; i <= b.length; i++) { 50 | for (j = 1; j <= a.length; j++) { 51 | if (b.charAt(i - 1) == a.charAt(j - 1)) { 52 | matrix[i][j] = matrix[i - 1][j - 1]; 53 | } else { 54 | matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, // substitution 55 | Math.min(matrix[i][j - 1] + 1, // insertion 56 | matrix[i - 1][j] + 1)); // deletion 57 | } 58 | } 59 | } 60 | 61 | return matrix[b.length][a.length]; 62 | }; 63 | 64 | export function capitalizeFirstLetter(s: string) { 65 | return s.charAt(0).toUpperCase() + s.slice(1); 66 | } 67 | 68 | export function camelCaseToSpacedString(toConvert: string): string { 69 | return capitalizeFirstLetter(toConvert.replace(/([A-Z])([A-Z])([a-z])|([a-z])([A-Z])/g, '$1$4 $2$3$5')); 70 | } 71 | 72 | export function genericServerError(err: any, res: Response) { 73 | BuildResponse(res, HttpStatus.InternalServerError, `Internal server error: ${err}`); 74 | } 75 | 76 | /** @summary Checks that the authentication header contains a valid auth token */ 77 | export function validateAuthenticationHeader(req: Request, res: Response, emitResponseOnFailure?: boolean): string | undefined { 78 | if (!req.headers.authorization) { 79 | if (emitResponseOnFailure !== false) BuildResponse(res, HttpStatus.MalformedRequest, "Missing authorization header"); 80 | return; 81 | } 82 | return req.headers.authorization.replace("Bearer ", ""); 83 | } 84 | 85 | export const DEVENV: boolean = process.env.environment == "development"; 86 | -------------------------------------------------------------------------------- /src/bot/commands/news.ts: -------------------------------------------------------------------------------- 1 | import { match } from '../../common/helpers/generic'; 2 | import { TextChannel, User, Message, Emoji, Client } from 'discord.js'; 3 | import { IBotCommandArgument } from '../../models/types'; 4 | import { GetChannelByName } from '../../common/helpers/discord'; 5 | 6 | const linkRegex = /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; 7 | 8 | let RecentPostsStore: { user: User; lastPost: number; }[] = []; 9 | 10 | /** 11 | * @summary Get a custom Discord emoji from a Client object using the name of the emoji 12 | * @param client Client object that has access to a users' emoji list 13 | * @param emojiText Name of emoji, without surrounding `:` characters 14 | */ 15 | function getDiscordEmoji(client: Client, emojiText: string): Emoji | undefined { 16 | emojiText = emojiText.split(":").join(""); 17 | return client.emojis.cache.find(emoji => emoji.name == emojiText); 18 | } 19 | 20 | export default async (discordMessage: Message, commandParts: string[], args: IBotCommandArgument[]) => { 21 | cleanupRecentPostsStore(); 22 | 23 | const link = match(commandParts[0], linkRegex); 24 | if (!link) return; // Must have a link 25 | 26 | // Can only post every 3 minutes 27 | for (let post of RecentPostsStore) { 28 | const UnixTimeNow = new Date().getTime(); 29 | if (post.user.id == discordMessage.author.id && UnixTimeNow - post.lastPost < 3 * 60 * 1000) { 30 | (discordMessage.channel as TextChannel).send(`<@${discordMessage.author.id}> You are doing that too much, please wait ${((3 * 60 * 1000 - (UnixTimeNow - post.lastPost)) / 60000).toFixed(2)} more minutes`); 31 | return; 32 | } 33 | } 34 | 35 | const commentArgs = args.find(arg => arg.name == "comment"); 36 | 37 | // Get just the user comment 38 | const comment = commentArgs?.value 39 | // Remove mentions 40 | .replace(/<@\d+>/g, "") 41 | // Remove the link 42 | .replace(linkRegex, "") 43 | // Remove all line breaks 44 | .replace(/\n/g, "") 45 | // Recreate server emojis 46 | .replace(/[^<]+[^a-z]+(?:\:(.*?)\:)/g, (toReplace: string) => { 47 | const emoji: Emoji | undefined = getDiscordEmoji(discordMessage.client, toReplace); 48 | if (emoji != undefined) return `:${emoji.id}:`; 49 | return ""; 50 | }) 51 | // Remove unsupported emojis 52 | .replace(/(?:<+[a-z]+\:.*?\:[^>]+>)/g, "") 53 | // Trim whitespace 54 | .trim(); 55 | 56 | postNewsLink(discordMessage, link, comment); 57 | } 58 | 59 | async function postNewsLink(discordMessage: Message, link: string, comment?: string) { 60 | // Get the news channel 61 | const channel: TextChannel = await GetChannelByName("news") as TextChannel; 62 | if (!channel) return; 63 | 64 | RecentPostsStore.push({ user: discordMessage.author, lastPost: new Date().getTime() }); 65 | 66 | // Special formatting if the sender included a comment other than the link 67 | if (comment && comment.length > 0) { 68 | await channel.send(`<@${discordMessage.author.id}> shared, and says:\n> ${comment}\n${link}`); 69 | } else { 70 | await channel.send(`<@${discordMessage.author.id}> shared:\n${link}`); 71 | } 72 | 73 | } 74 | 75 | function cleanupRecentPostsStore() { 76 | const UnixTimeNow = new Date().getTime(); 77 | RecentPostsStore = RecentPostsStore.filter(data => UnixTimeNow - data.lastPost < 3 * 60 * 1000); 78 | } 79 | -------------------------------------------------------------------------------- /src/bot/commands/getuser.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel, Guild, GuildMember } from "discord.js"; 2 | import { GetGuild, GetGuildMembers } from "../../common/helpers/discord"; 3 | import { IBotCommandArgument } from "../../models/types"; 4 | import { getUserByDiscordId } from "../../models/User"; 5 | 6 | const validFindByMethod = ["discordId", "username"]; 7 | 8 | export default async (discordMessage: Message, commandParts: string[], args: IBotCommandArgument[]) => { 9 | const sentFromChannel = (discordMessage.channel as TextChannel) as TextChannel; 10 | 11 | if (args.length == 0) { 12 | sentFromChannel.send(`No parameters provided. Provide one of: \`${validFindByMethod.join(', ')}\``); 13 | return; 14 | } 15 | 16 | const arg = args[0]; 17 | 18 | if (args.length > 1) { 19 | sentFromChannel.send(`Too many parameters. Provide one of: \`${validFindByMethod.join(', ')}\``); 20 | return; 21 | } 22 | 23 | if (!validFindByMethod.includes(arg.name)) { 24 | sentFromChannel.send(`Invalid parameter. Provide one of: \`${validFindByMethod.join(', ')}\``); 25 | return; 26 | } 27 | 28 | for (let method of validFindByMethod) { 29 | if (method != arg.name) 30 | continue; 31 | else 32 | await handleFind(arg, discordMessage); 33 | } 34 | }; 35 | 36 | 37 | async function handleFind(arg: IBotCommandArgument, discordMessage: Message) { 38 | const server = await GetGuild(); 39 | if (!server) return; 40 | 41 | switch (arg.name) { 42 | case "discordId": 43 | await findByDiscordId(discordMessage, server, arg.value); 44 | break; 45 | case "username": 46 | await findByUsername(discordMessage, server, arg.value); 47 | break; 48 | } 49 | } 50 | 51 | async function findByDiscordId(discordMessage: Message, server: Guild, discordId: string) { 52 | const members = await GetGuildMembers(); 53 | if (!members) { 54 | (discordMessage.channel as TextChannel).send("error: couldn't get members list"); 55 | return; 56 | } 57 | 58 | const member = members.find(i => i.id == discordId); 59 | if (!member) 60 | (discordMessage.channel as TextChannel).send("Could not find a user with that ID"); 61 | else 62 | sendFormattedUserInfo((discordMessage.channel as TextChannel) as TextChannel, member); 63 | } 64 | 65 | async function findByUsername(discordMessage: Message, server: Guild, username: string) { 66 | const members = await GetGuildMembers(); 67 | if (!members) { 68 | (discordMessage.channel as TextChannel).send("error: couldn't get members list"); 69 | return; 70 | } 71 | 72 | const member = members.find(i => `${i.user.username}#${i.user.discriminator}` == username); 73 | if (!member) 74 | (discordMessage.channel as TextChannel).send("Could not find a user with that ID"); 75 | else 76 | sendFormattedUserInfo((discordMessage.channel as TextChannel) as TextChannel, member); 77 | } 78 | 79 | export async function sendFormattedUserInfo(channel: TextChannel, member: GuildMember) { 80 | let formattedUserInfo = 81 | `Discord Id: \`${member.id}\` 82 | Current username: \`${member.user.username}#${member.user.discriminator}\` 83 | Nickname: \`${member.nickname}\` 84 | Joined: \`${member.joinedAt?.toUTCString()}\``; 85 | 86 | const userData = await getUserByDiscordId(member.id); 87 | if (userData) { 88 | formattedUserInfo += ` 89 | Registered name: \`${userData.name}\` 90 | Registered Email: \`${userData.email}\``; 91 | } 92 | 93 | channel.send(formattedUserInfo); 94 | } -------------------------------------------------------------------------------- /src/bot/events/messageHandlers/swearFilter.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel, DMChannel, PartialMessage } from "discord.js"; 2 | import { GetGuild } from "../../../common/helpers/discord"; 3 | 4 | export const swearRegex: RegExp = new RegExp(/fuck|\sass\s|dick|shit|pussy|cunt|whore|bastard|bitch|faggot|penis|slut|retarded/); 5 | 6 | export const whitelist: RegExp = new RegExp(/ishittest/); 7 | 8 | export async function handleSwearFilter(discordMessage: PartialMessage | Message) { 9 | const message = discordMessage.content?.toLowerCase() ?? ""; // A partial update might be just adding an embed, so no need to check content again 10 | const checks = [message]; 11 | discordMessage.embeds.forEach(e => checks.push(e.title?.toLowerCase() ?? "", e.description?.toLowerCase() ?? "", e.author?.name?.toLowerCase() ?? "")); 12 | let isEmbed = false; // We will show a different message if the swear is in the embed part 13 | 14 | for (const check of checks) { 15 | if (check.match(swearRegex) && !check.match(whitelist)) { 16 | const sentFromChannel = discordMessage.channel as TextChannel; 17 | if (!discordMessage.guild?.roles.everyone) 18 | return; 19 | 20 | const channelPermsForAll = sentFromChannel.permissionsFor(discordMessage.guild.roles.everyone); 21 | 22 | // If the channel is private, don't filter 23 | if (channelPermsForAll && !channelPermsForAll.has(["ViewChannel"])) { 24 | return; 25 | } 26 | 27 | await discordMessage.fetch(); // Partial messages need to be resolved at this point. Does nothing if not partial 28 | if (!discordMessage.partial) { // This allows us to access properties that could have been null before fetching 29 | try { 30 | // If the user has turned off DMs from all server members, this will throw 31 | const dm: DMChannel = await discordMessage.author.createDM(); 32 | await dm.send(`Your message was removed because it contained a swear word${isEmbed ? " in an embed" : ""}. 33 | > ${discordMessage.content}`); 34 | } catch { 35 | var tick = 5; 36 | var baseMsg = `<@${discordMessage.author.id}> Swear word was removed, see rule 4.\nThis message will self destruct in `; 37 | var sentMsg = await (discordMessage.channel as TextChannel).send(baseMsg + tick); 38 | 39 | var interval = setInterval(() => { 40 | tick--; 41 | 42 | if (tick == 0) { 43 | sentMsg.delete(); 44 | clearInterval(interval); 45 | return; 46 | } 47 | 48 | sentMsg.edit(baseMsg + tick); 49 | }, 1000); 50 | } 51 | 52 | const guild = await GetGuild(); 53 | const author = discordMessage.author; 54 | if (guild) { 55 | const botChannel = guild.channels.cache.find(i => i.name == "bot-stuff") as TextChannel; 56 | botChannel.send(`A swear word from \`${author.username}#${author.discriminator}\` (ID ${author.id}) sent in <#${sentFromChannel.id}> was removed: 57 | > ${discordMessage.content}${isEmbed ? "\n\nOffending part of embed:\n> " + check : ""}`); 58 | } 59 | } 60 | 61 | await discordMessage.delete(); 62 | 63 | break; 64 | } 65 | 66 | isEmbed = true; // First iteration will always be message content, even if empty 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/common/helpers/discord.ts: -------------------------------------------------------------------------------- 1 | import * as Discord from "discord.js"; 2 | import fetch from "node-fetch"; 3 | import { IDiscordUser } from "../../models/types"; 4 | import { Response } from "express"; 5 | import { genericServerError } from "./generic"; 6 | import { BuildResponse, HttpStatus } from "./responseHelper"; 7 | import { ChannelType, GatewayIntentBits } from "discord.js"; 8 | 9 | export let bot: Discord.Client; 10 | export const uwpCommunityGuildId: string = process.env.guildId || "667491687639023636"; 11 | 12 | export let InitBot = function () { 13 | bot = new Discord.Client({ 14 | intents: [ 15 | GatewayIntentBits.Guilds, 16 | GatewayIntentBits.GuildMessages, 17 | GatewayIntentBits.GuildMembers, 18 | GatewayIntentBits.GuildMessageReactions, 19 | GatewayIntentBits.GuildPresences, 20 | GatewayIntentBits.MessageContent, 21 | GatewayIntentBits.DirectMessageReactions, 22 | GatewayIntentBits.DirectMessages, 23 | ], 24 | }); 25 | 26 | if (!process.env.discord_botToken) { 27 | console.log(`\x1b[33m${`Missing "discord_botToken" environment variable. You will not be able to interact with the Discord bot without this`}\x1b[0m`); 28 | return; 29 | } 30 | 31 | bot.once('ready', () => { 32 | console.log("Server Companion bot initialized"); 33 | InitBot = () => { }; // Prevents init from being called again 34 | }); 35 | 36 | bot.login(process.env.discord_botToken); 37 | }; 38 | 39 | 40 | export function GetGuild(): Promise { 41 | return bot.guilds.fetch(uwpCommunityGuildId); 42 | } 43 | 44 | export async function GetGuildMembers(): Promise { 45 | const server = await GetGuild(); 46 | if (!server) return; 47 | 48 | var members = await server.members.fetch(); 49 | 50 | return [...members.values()]; 51 | } 52 | 53 | export async function GetGuildUser(discordId: string): Promise { 54 | const server = await GetGuild(); 55 | if (!server) return; 56 | 57 | return (server.members.cache.filter(member => member.id == discordId)).first(); 58 | } 59 | 60 | export function GetUser(discordId: string) { 61 | return bot.users.fetch(discordId) 62 | } 63 | 64 | export async function GetRoles() { 65 | const server = await GetGuild(); 66 | if (!server) return; 67 | 68 | return [...(await server.roles.fetch()).values()]; 69 | } 70 | 71 | export async function GetGuildChannels(): Promise { 72 | const server = await GetGuild(); 73 | 74 | if (server == undefined) 75 | return undefined; 76 | 77 | return [...server!.channels.cache.values()]; 78 | } 79 | 80 | export async function GetChannelByName(channelName: string): Promise { 81 | const channels = await GetGuildChannels(); 82 | if (!channels) 83 | return; 84 | 85 | let requestedChannel = channels.find(i => i.name == channelName); 86 | if (!requestedChannel) { 87 | requestedChannel = channels.find(i => i.name == "mod-chat"); 88 | (requestedChannel as Discord.TextChannel).send(`Bot tried to find channel ${channelName} but failed.`); 89 | } 90 | 91 | return requestedChannel; 92 | } 93 | 94 | export async function EditMultiMessages(content: string, ...params: Discord.Message[]): Promise { 95 | for (const message of params) { 96 | await message.edit(content); 97 | } 98 | } 99 | 100 | export async function SendMultiMessages(content: string, ...params: (Discord.GuildChannel | Discord.TextChannel | Discord.DMChannel)[]): Promise { 101 | const results: Discord.Message[] = []; 102 | 103 | for (const channel of params) { 104 | if (channel.type === ChannelType.GuildText) { 105 | const sentMessage = await (channel as Discord.TextChannel).send(content); 106 | results.push(sentMessage as Discord.Message); 107 | } 108 | } 109 | 110 | return results; 111 | } 112 | 113 | export async function GetDiscordUser(accessToken: string): Promise { 114 | const Req = await fetch("https://discordapp.com/api/v6/users/@me", { 115 | headers: { 116 | "Authorization": "Bearer " + accessToken 117 | } 118 | }); 119 | if (!Req || Req.status != 200) return; 120 | return await Req.json(); 121 | } 122 | 123 | export async function GetDiscordIdFromToken(accessToken: string, res: Response, emitResponseOnFailure?: boolean): Promise { 124 | const user = await GetDiscordUser(accessToken).catch((err) => genericServerError(err, res)); 125 | if (!user) { 126 | if (emitResponseOnFailure !== false) BuildResponse(res, HttpStatus.Unauthorized, "Invalid accessToken"); 127 | return; 128 | } 129 | return (user as IDiscordUser).id; 130 | } -------------------------------------------------------------------------------- /src/api/projects/tags/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { GetDiscordIdFromToken, GetGuildUser } from "../../../common/helpers/discord"; 3 | import { validateAuthenticationHeader } from "../../../common/helpers/generic"; 4 | import { BuildResponse, HttpStatus, IRequestPromiseReject, ResponsePromiseReject } from "../../../common/helpers/responseHelper"; 5 | import { getAllDbProjects, RefreshProjectCache } from "../../../models/Project"; 6 | import ProjectTag from "../../../models/ProjectTag"; 7 | import Tag from "../../../models/Tag"; 8 | import { IProject, ITag } from "../../../models/types"; 9 | import { UserOwnsProject } from "../../../models/UserProject"; 10 | 11 | module.exports = async (req: Request, res: Response) => { 12 | const body = req.body as IDeleteProjectTagsRequestBody; 13 | const reqQuery = req.query as IDeleteProjectTagsRequestQuery; 14 | 15 | const authAccess = validateAuthenticationHeader(req, res); 16 | if (!authAccess) return; 17 | 18 | let discordId = await GetDiscordIdFromToken(authAccess, res); 19 | if (!discordId) return; 20 | 21 | const queryValidation = checkQuery(reqQuery); 22 | if (queryValidation !== true) { 23 | res.send(queryValidation); 24 | return; 25 | } 26 | 27 | const bodyCheck = checkBody(body); 28 | if (bodyCheck !== true) { 29 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${bodyCheck}" not provided or malformed`); 30 | return; 31 | } 32 | 33 | if (!checkPermission(body, reqQuery, discordId)) { 34 | res.status(HttpStatus.Unauthorized).send("Unauthorized user"); 35 | return; 36 | } 37 | 38 | removeTag(body, reqQuery, discordId) 39 | .then(() => { 40 | BuildResponse(res, HttpStatus.Success, "Success"); 41 | RefreshProjectCache(); 42 | }) 43 | .catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 44 | }; 45 | 46 | function checkBody(body: IDeleteProjectTagsRequestBody): true | string { 47 | if (!body.name) return "name"; 48 | 49 | return true; 50 | } 51 | 52 | function checkQuery(query: IDeleteProjectTagsRequestQuery): true | string { 53 | if (query.projectId && query.appName) 54 | return "Only one of 'projectId' or 'appName' should be specified."; 55 | 56 | if (query.projectId == undefined && query.appName == undefined) 57 | return "Either 'projectId' or 'appName' should be specified."; 58 | 59 | return true; 60 | } 61 | 62 | async function removeTag(body: IDeleteProjectTagsRequestBody, query: IDeleteProjectTagsRequestQuery, discordId: string) { 63 | return new Promise(async (resolve, reject) => { 64 | const tag = body as ITag; 65 | 66 | const allDbProjects = await getAllDbProjects(); 67 | 68 | const project = allDbProjects.filter(x => x.appName == query.appName || x.id == query.projectId)[0]; 69 | 70 | if (!project.tags) { 71 | ResponsePromiseReject("No tags found on this project.", HttpStatus.BadRequest, reject); 72 | return; 73 | } 74 | 75 | // Tag needs to be added only if it doesn't exist. 76 | const matchingTags = project.tags.filter(x => x.name == tag.name || x.id == tag.id); 77 | const tagExists = matchingTags.length ?? 0 > 0; 78 | 79 | if (!tagExists) { 80 | ResponsePromiseReject("Tag doesn't exist on this project.", HttpStatus.BadRequest, reject); 81 | return; 82 | } 83 | 84 | if (tagExists) { 85 | var projectTags = await ProjectTag.findAll(); 86 | var relevantProjectTags = projectTags.filter(x => x.tagId == matchingTags[0].id); 87 | 88 | relevantProjectTags[0].destroy() 89 | .then(resolve) 90 | .catch(err => ResponsePromiseReject(err, HttpStatus.InternalServerError, reject)); 91 | 92 | RefreshProjectCache(); 93 | return; 94 | } 95 | 96 | resolve(); 97 | }); 98 | } 99 | 100 | async function checkPermission(body: IDeleteProjectTagsRequestBody, query: IDeleteProjectTagsRequestQuery, discordId: string): Promise { 101 | return new Promise(async (resolve, reject) => { 102 | const allDbProjects = await getAllDbProjects(); 103 | const matchingDbProjects = allDbProjects.filter(x => x.appName == query.appName || x.id == query.projectId); 104 | 105 | if (matchingDbProjects.length == 0) { 106 | ResponsePromiseReject("No project found.", HttpStatus.BadRequest, reject); 107 | return; 108 | } 109 | 110 | const guildMember = await GetGuildUser(discordId); 111 | const isMod = guildMember && [...guildMember.roles.cache.values()].filter(role => role.name.toLowerCase() === "mod" || role.name.toLowerCase() === "admin").length > 0; 112 | 113 | const relevantUser = matchingDbProjects[0].users?.filter(x => x.discordId == discordId); 114 | if (relevantUser?.length ?? 0 === 0) { 115 | ResponsePromiseReject("No user found.", HttpStatus.Unauthorized, reject); 116 | return; 117 | } 118 | 119 | const userOwnsProject: boolean = await UserOwnsProject(relevantUser![0], matchingDbProjects[0]); 120 | const userCanModify = isMod || userOwnsProject; 121 | 122 | if (!userCanModify) { 123 | resolve(false); 124 | } else { 125 | ResponsePromiseReject("No permission to edit project.", HttpStatus.Unauthorized, reject); 126 | } 127 | }); 128 | } 129 | 130 | 131 | type IDeleteProjectTagsRequestBody = ITag; 132 | 133 | interface IDeleteProjectTagsRequestQuery { 134 | appName?: string; 135 | projectId?: number; 136 | } 137 | -------------------------------------------------------------------------------- /src/api/projects/tags/post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { GetDiscordIdFromToken, GetGuildUser } from "../../../common/helpers/discord"; 3 | import { genericServerError, validateAuthenticationHeader } from "../../../common/helpers/generic"; 4 | import { BuildResponse, HttpStatus, IRequestPromiseReject, ResponsePromiseReject } from "../../../common/helpers/responseHelper"; 5 | import { getAllDbProjects, RefreshProjectCache } from "../../../models/Project"; 6 | import ProjectTag from "../../../models/ProjectTag"; 7 | import Tag from "../../../models/Tag"; 8 | import { IProject, ITag } from "../../../models/types"; 9 | import { UserOwnsProject } from "../../../models/UserProject"; 10 | 11 | module.exports = async (req: Request, res: Response) => { 12 | const body = req.body as IPostProjectTagsRequestBody; 13 | const reqQuery = req.query as IPostProjectTagsRequestQuery; 14 | 15 | const authAccess = validateAuthenticationHeader(req, res); 16 | if (!authAccess) return; 17 | 18 | let discordId = await GetDiscordIdFromToken(authAccess, res); 19 | if (!discordId) return; 20 | 21 | const queryValidation = checkQuery(reqQuery); 22 | if (queryValidation !== true) { 23 | res.send(queryValidation); 24 | return; 25 | } 26 | 27 | const bodyCheck = checkBody(body); 28 | if (bodyCheck !== true) { 29 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${bodyCheck}" not provided or malformed`); 30 | return; 31 | } 32 | 33 | if (!await checkPermission(body, reqQuery, discordId).catch((err) => genericServerError(err, res))) { 34 | res.status(HttpStatus.Unauthorized); 35 | return; 36 | } 37 | 38 | createTag(body, reqQuery, discordId) 39 | .then(() => { 40 | BuildResponse(res, HttpStatus.Success, "Success"); 41 | RefreshProjectCache(); 42 | }) 43 | .catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 44 | }; 45 | 46 | function checkBody(body: IPostProjectTagsRequestBody): true | string { 47 | if (!body.tagName) return "tagName"; 48 | 49 | return true; 50 | } 51 | 52 | function checkQuery(query: IPostProjectTagsRequestQuery): true | string { 53 | if (query.projectId && query.appName) 54 | return "Only one of 'projectId' or 'appName' should be specified."; 55 | 56 | if (query.projectId == undefined && query.appName == undefined) 57 | return "Either 'projectId' or 'appName' should be specified."; 58 | 59 | return true; 60 | } 61 | 62 | async function checkPermission(body: IPostProjectTagsRequestBody, query: IPostProjectTagsRequestQuery, discordId: string): Promise { 63 | return new Promise(async (resolve, reject) => { 64 | const tag = body as IPostProjectTagsRequestBody; 65 | 66 | const allDbProjects = await getAllDbProjects(); 67 | const matchingDbProjects = allDbProjects.filter(x => x.appName == query.appName || x.id == query.projectId); 68 | 69 | if (matchingDbProjects.length == 0) { 70 | ResponsePromiseReject("No project found.", HttpStatus.BadRequest, reject); 71 | return; 72 | } 73 | 74 | const guildMember = await GetGuildUser(discordId); 75 | const isMod = guildMember && [...guildMember.roles.cache.values()].filter(role => role.name.toLowerCase() === "mod" || role.name.toLowerCase() === "admin").length > 0; 76 | const isLaunchCoordinator = guildMember && ([...guildMember.roles.cache.values()].filter(role => role.name.toLowerCase() === "launch coordinator").length ?? 0) > 0; 77 | 78 | const relevantUser = matchingDbProjects[0].users?.filter(x => x.discordId == discordId); 79 | if ((relevantUser?.length ?? 0) === 0 && !isMod) { 80 | ResponsePromiseReject("No user found.", HttpStatus.Unauthorized, reject); 81 | return; 82 | } 83 | 84 | const isLaunchTag = tag.tagName?.includes("Launch ") ?? false; 85 | 86 | let userOwnsProject: boolean = false; 87 | 88 | if (relevantUser && relevantUser.length > 0) 89 | userOwnsProject = await UserOwnsProject(relevantUser[0], matchingDbProjects[0]); 90 | 91 | // Only launch coordinators can add a launch tag to a project. 92 | const userCanModify = (isLaunchTag && isLaunchCoordinator) || (isMod && !isLaunchTag && !isLaunchCoordinator) || (userOwnsProject && !isLaunchTag); 93 | 94 | resolve(userCanModify); 95 | }); 96 | } 97 | 98 | async function createTag(body: IPostProjectTagsRequestBody, query: IPostProjectTagsRequestQuery, discordId: string) { 99 | return new Promise(async (resolve, reject) => { 100 | const tag = body as IPostProjectTagsRequestBody; 101 | 102 | const allDbProjects = await getAllDbProjects(); 103 | const matchingDbProjects = allDbProjects.filter(x => x.appName == query.appName || x.id == query.projectId); 104 | 105 | if (matchingDbProjects.length == 0) { 106 | ResponsePromiseReject("No project found.", HttpStatus.BadRequest, reject); 107 | return; 108 | } 109 | 110 | const project = matchingDbProjects[0]; 111 | 112 | if (!project.tags) { 113 | ResponsePromiseReject("No tags were supplied.", HttpStatus.BadRequest, reject); 114 | return; 115 | } 116 | 117 | // Tag needs to be added only if it doesn't exist. 118 | const tagExists = project.tags.filter(x => x.name == tag.tagName || x.id == tag.tagId).length > 0; 119 | 120 | if (!tagExists) { 121 | var dbTags = await Tag.findAll(); 122 | var dbTag = dbTags.filter(x => x.id == body.tagId || x.name == body.tagName)[0]; 123 | 124 | ProjectTag.create({ 125 | projectId: project.id, 126 | tagId: dbTag.id, 127 | }); 128 | } else { 129 | ResponsePromiseReject("Tag already exists on project.", HttpStatus.BadRequest, reject); 130 | return; 131 | } 132 | 133 | resolve(); 134 | }); 135 | } 136 | 137 | interface IPostProjectTagsRequestBody { 138 | tagName: string; 139 | tagId?: number; 140 | } 141 | 142 | interface IPostProjectTagsRequestQuery { 143 | appName?: string; 144 | projectId?: number; 145 | } 146 | -------------------------------------------------------------------------------- /src/api/projects/post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import Project, { StdToDbModal_Project, isExistingProject, RefreshProjectCache, ProjectFieldsAreValid } from "../../models/Project"; 3 | import { genericServerError, validateAuthenticationHeader, match } from "../../common/helpers/generic"; 4 | import UserProject, { GetProjectsByUserId } from "../../models/UserProject"; 5 | import { GetRoleByName } from "../../models/Role"; 6 | import { getUserByDiscordId } from "../../models/User"; 7 | import { GetDiscordIdFromToken } from "../../common/helpers/discord"; 8 | import { BuildResponse, HttpStatus, } from "../../common/helpers/responseHelper"; 9 | import ProjectImage from "../../models/ProjectImage"; 10 | import { IProject, ITag } from "../../models/types"; 11 | import ProjectFeature from "../../models/ProjectFeature"; 12 | import ProjectTag from "../../models/ProjectTag"; 13 | 14 | module.exports = async (req: Request, res: Response) => { 15 | const body = req.body as IPostProjectsRequestBody; 16 | 17 | body.images == body.images ?? []; 18 | 19 | const authAccess = validateAuthenticationHeader(req, res); 20 | if (!authAccess) 21 | return; 22 | 23 | let discordId = await GetDiscordIdFromToken(authAccess, res); 24 | if (!discordId) 25 | return; 26 | 27 | const bodyCheck = checkBody(body); 28 | 29 | if (bodyCheck !== true) { 30 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${bodyCheck}" not provided or malformed`); 31 | return; 32 | } 33 | 34 | if (!await ProjectFieldsAreValid(body as unknown as IProject, res)) 35 | return; 36 | 37 | submitProject(body, discordId) 38 | .then(() => { 39 | BuildResponse(res, HttpStatus.Success, "Success"); 40 | RefreshProjectCache(); 41 | }) 42 | .catch((err) => genericServerError(err, res)); 43 | }; 44 | 45 | function checkBody(body: IPostProjectsRequestBody): true | string { 46 | if (!body.appName) return "appName"; 47 | if (!body.description) return "description"; 48 | if (!body.role) return "role"; 49 | if (!body.category) return "category"; 50 | if (!body.heroImage) return "heroImage"; 51 | if (body.isPrivate == undefined) return "isPrivate"; 52 | return true; 53 | } 54 | 55 | function submitProject(projectRequestData: IPostProjectsRequestBody, discordId: any): Promise { 56 | return new Promise(async (resolve, reject) => { 57 | 58 | if (await isExistingProject(projectRequestData.appName).catch(reject)) { 59 | reject("A project with that name already exists"); 60 | return; 61 | } 62 | 63 | // Get a matching user 64 | const user = await getUserByDiscordId(discordId).catch(reject); 65 | if (!user) { 66 | reject("User not found"); 67 | return; 68 | } 69 | 70 | const role = await GetRoleByName(projectRequestData.role); 71 | if (!role) { 72 | reject("Invalid role"); 73 | return; 74 | } 75 | 76 | const existingUserProjects = await GetProjectsByUserId(user.id, true); 77 | 78 | if (existingUserProjects.length > 15) { 79 | reject("User has reached or exceeded project limit"); 80 | return; 81 | } 82 | 83 | // If review status is unspecified, default to true 84 | if (projectRequestData.needsManualReview == undefined) projectRequestData.needsManualReview = true; 85 | 86 | var projectData = await StdToDbModal_Project({ ...projectRequestData }); 87 | 88 | // Create the project 89 | await Project.create(projectData).catch(reject); 90 | 91 | var project = await Project.findAll({ where: { appName: projectData.appName ?? "" } }) as Project[]; 92 | 93 | if (!project || project.length === 0) 94 | return; 95 | 96 | // Create the userproject 97 | await UserProject.create( 98 | { 99 | userId: user.id, 100 | projectId: project[0].id, 101 | isOwner: true, // Only the project owner can create the project 102 | roleId: role.id 103 | }) 104 | .then(() => createImages(projectRequestData, project[0])) 105 | .then(() => createFeatures(projectRequestData, project[0])) 106 | .then(() => createTags(projectRequestData, project[0])) 107 | .catch(reject); 108 | 109 | resolve(project[0]); 110 | }); 111 | } 112 | 113 | function createImages(projectRequestData: IPostProjectsRequestBody, project: Project): Promise { 114 | return new Promise(async (resolve, reject) => { 115 | 116 | for (let url of projectRequestData.images ?? []) { 117 | if (url.length == 0 || url.length > 300) 118 | continue; 119 | 120 | await ProjectImage.create( 121 | { 122 | projectId: project.id, 123 | imageUrl: url 124 | }).catch(reject); 125 | } 126 | 127 | resolve(); 128 | }); 129 | } 130 | 131 | function createFeatures(projectRequestData: IPostProjectsRequestBody, project: Project): Promise { 132 | return new Promise(async (resolve, reject) => { 133 | 134 | for (let feature of projectRequestData.features ?? []) { 135 | if (feature.length == 0 || feature.length > 240) 136 | continue; 137 | 138 | await ProjectFeature.create( 139 | { 140 | projectId: project.id, 141 | feature: feature 142 | }).catch(reject); 143 | } 144 | 145 | resolve(); 146 | }); 147 | } 148 | 149 | function createTags(projectRequestData: IPostProjectsRequestBody, project: Project): Promise { 150 | return new Promise(async (resolve, reject) => { 151 | for (let tag of projectRequestData.tags ?? []) { 152 | 153 | if (!tag.id) 154 | continue; 155 | 156 | await ProjectTag.create( 157 | { 158 | projectId: project.id, 159 | tagId: tag.id, 160 | }).catch(reject); 161 | } 162 | 163 | resolve(); 164 | }); 165 | } 166 | 167 | interface IPostProjectsRequestBody { 168 | role: "Developer"; // Only a developer can create a new project 169 | appName: string; 170 | category: string; 171 | description: string; 172 | isPrivate: boolean; 173 | downloadLink?: string; 174 | githubLink?: string; 175 | externalLink?: string; 176 | awaitingLaunchApproval: boolean; 177 | needsManualReview: boolean; 178 | images?: string[]; 179 | features?: string[]; 180 | tags?: ITag[]; 181 | heroImage: string; 182 | appIcon?: string; 183 | accentColor?: string; 184 | lookingForRoles: string[]; 185 | } 186 | -------------------------------------------------------------------------------- /src/bot/commands/staffpoll.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, APIActionRowComponent, APIEmbed, APIEmbedField, ButtonBuilder, ButtonStyle, EmbedType, Events, GuildMember, Interaction, InteractionType, Message, MessageActionRowComponentData, MessageCollector, ModalBuilder, TextChannel, TextInputBuilder, TextInputStyle } from "discord.js"; 2 | import { col } from "sequelize/types"; 3 | import { GetGuildMembers, GetRoles } from "../../common/helpers/discord"; 4 | import { GetRoleByName } from "../../models/Role"; 5 | import { IBotCommandArgument } from "../../models/types"; 6 | 7 | export interface StaffPollResponse { 8 | member: GuildMember; 9 | value?: string; 10 | pollMessage?: Message; 11 | } 12 | 13 | export interface StaffPoll { 14 | name: string; 15 | description: string; 16 | responses: StaffPollResponse[]; 17 | } 18 | 19 | export default async (message: Message, commandParts: string[], args: IBotCommandArgument[]) => { 20 | const sentFromChannel = message.channel as TextChannel; 21 | const pollId = Guid.newGuid(); 22 | 23 | if (!message.member?.roles.cache.find(i => i.name.toLowerCase() == "admin")) { 24 | message.reply("Only Admins can create staff polls.") 25 | } 26 | 27 | var name = args.find(x => x.name.toLowerCase() == "name")?.value; 28 | var description = args.find(x => x.name.toLowerCase() == "description")?.value; 29 | var isAnonymous = commandParts.includes("anonymous"); 30 | 31 | if (args.length == 0) { 32 | sentFromChannel.send(`No parameters provided. Command usage: !staffpoll -name "Moderator nominations" -description "Nominate a server member for the mod position."`); 33 | return; 34 | } 35 | 36 | if (!name) { 37 | sentFromChannel.send(`Missing name parameter. Command usage: !staffpoll -name "Moderator nominations" -description "Nominate a server member for the mod position."`); 38 | return; 39 | } 40 | 41 | if (!description) { 42 | sentFromChannel.send(`Missing description parameter. Command usage: !staffpoll -name "Moderator nominations" -description "Nominate a server member for the mod position."`); 43 | return; 44 | } 45 | 46 | var thisPoll: StaffPoll = { name: name, description: description, responses: [] }; 47 | 48 | var guildMembers = await GetGuildMembers(); 49 | if (!guildMembers) { 50 | sentFromChannel.send(`Error: no staff members were found.`); 51 | return; 52 | } 53 | 54 | var allRoles = await GetRoles(); 55 | if (allRoles == undefined) { 56 | sentFromChannel.send(`Error: couldn't retrieve roles.`); 57 | return; 58 | } 59 | 60 | var staffRole = allRoles.find(x => x.name.toLowerCase() == "staff"); 61 | if (staffRole == undefined || staffRole == null) { 62 | sentFromChannel.send(`Error: couldn't find staff role.`); 63 | return; 64 | } 65 | 66 | var staffMembers = guildMembers.filter(x => x.roles.cache.has(staffRole!.id)); 67 | 68 | message.client.on(Events.InteractionCreate, async (interaction: Interaction) => { 69 | var staffMember = staffMembers.find(x => x.id == interaction.user.id); 70 | if (!staffMember) 71 | return; 72 | 73 | if (interaction.isButton()) { 74 | if (interaction.customId === `staffpoll-button-${staffMember.id}-${name}-${pollId}`) { 75 | const modal = new ModalBuilder() 76 | .setCustomId(`staffpoll-modal-${staffMember.id}-${name}-${pollId}`) 77 | .setTitle('Staff poll') 78 | .addComponents([ 79 | new ActionRowBuilder().addComponents( 80 | new TextInputBuilder() 81 | .setCustomId(`staffpoll-input-${staffMember.id}-${name}-${pollId}`) 82 | .setLabel('Submit') 83 | .setStyle(TextInputStyle.Paragraph) 84 | .setMinLength(2) 85 | .setRequired(true) 86 | ), 87 | ]); 88 | 89 | await interaction.showModal(modal); 90 | } 91 | } 92 | 93 | if (interaction.type === InteractionType.ModalSubmit) { 94 | if (interaction.customId === `staffpoll-modal-${staffMember.id}-${name}-${pollId}`) { 95 | var response = thisPoll.responses.find(x => x.member?.id == staffMember!.id); 96 | if (!response) { 97 | sentFromChannel.send(`Internal error: could not locate the member response for <@${staffMember.id}>`); 98 | return; 99 | } 100 | 101 | response.value = interaction.fields.getTextInputValue(`staffpoll-input-${staffMember.id}-${name}-${pollId}`); 102 | 103 | interaction.reply(`Thank you, your response has been collected.`); 104 | 105 | // If this is the last response, complete the poll and report the results. 106 | if (thisPoll.responses.filter(x => x.value != undefined).length == staffMembers?.length) { 107 | EndStaffPoll(staffMembers, thisPoll, sentFromChannel, name, description); 108 | } 109 | } 110 | } 111 | }); 112 | 113 | for (var staffMember of staffMembers) { 114 | let button = new ActionRowBuilder(); 115 | 116 | button.addComponents( 117 | new ButtonBuilder() 118 | .setCustomId(`staffpoll-button-${staffMember.id}-${name}-${pollId}`) 119 | .setStyle(ButtonStyle.Primary) 120 | .setLabel('Submit your vote'), 121 | ); 122 | 123 | var sentMessage = await staffMember.send({ 124 | content: "A staff poll has started", 125 | embeds: [ 126 | { 127 | "title": name, 128 | "description": description, 129 | "footer": { 130 | "text": `Submit your response. Your reply will be held in our server's memory until all staff members have responded. ` 131 | } 132 | } 133 | ], 134 | components: [button], 135 | }); 136 | 137 | var response: StaffPollResponse = { pollMessage: sentMessage, member: staffMember }; 138 | thisPoll.responses.push(response); 139 | } 140 | 141 | sentFromChannel.send(`A poll has been sent to each staff member's DM. The results will be posted here in <#${sentFromChannel.id}> when all results have been received.`); 142 | 143 | function EndStaffPoll(staffMembers: GuildMember[], thisPoll: StaffPoll, sentFromChannel: TextChannel, name: string | undefined, description: string | undefined) { 144 | var embeds: APIEmbed[] = [{ 145 | "title": `Staff Poll Results: ${name}`, 146 | "description": description 147 | }]; 148 | 149 | for (const staffMember of staffMembers) { 150 | var memberResponse = thisPoll.responses.find(x => x.member?.id == staffMember.id); 151 | if (!memberResponse?.pollMessage) { 152 | sentFromChannel.send(`Internal error: could not locate the member response for <@${staffMember.id}>`); 153 | return; 154 | } 155 | 156 | embeds.push({ 157 | description: memberResponse.value ?? "Empty or missing response", 158 | author: { 159 | name: isAnonymous ? "Anonymous" : memberResponse.member.displayName, 160 | icon_url: isAnonymous ? undefined : memberResponse.member.displayAvatarURL() 161 | } 162 | }); 163 | } 164 | 165 | sentFromChannel.send({ 166 | content: "A staff poll has ended", 167 | embeds: embeds, 168 | }); 169 | } 170 | } 171 | 172 | class Guid { 173 | static newGuid() { 174 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 175 | var r = Math.random() * 16 | 0, 176 | v = c == 'x' ? r : (r & 0x3 | 0x8); 177 | return v.toString(16); 178 | }); 179 | } 180 | } -------------------------------------------------------------------------------- /src/api/projects/collaborators/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User, { getUserByDiscordId } from "../../../models/User" 3 | import { getAllProjects } from "../../../models/Project"; 4 | import { genericServerError, validateAuthenticationHeader } from '../../../common/helpers/generic'; 5 | import { IProject } from "../../../models/types"; 6 | import { GetDiscordIdFromToken, GetGuildMembers, GetGuildUser, GetRoles } from "../../../common/helpers/discord"; 7 | import { BuildResponse, HttpStatus, ResponsePromiseReject, IRequestPromiseReject } from "../../../common/helpers/responseHelper"; 8 | import UserProject, { GetProjectCollaborators, GetProjectsByUserId } from "../../../models/UserProject"; 9 | import { GuildMember, Role } from "discord.js"; 10 | import { GetRoleByName } from "../../../models/Role"; 11 | import { Sequelize } from "sequelize-typescript"; 12 | 13 | module.exports = async (req: Request, res: Response) => { 14 | const body = req.body as IDeleteProjectCollaboratorRequestBody; 15 | 16 | const authAccess = validateAuthenticationHeader(req, res); 17 | if (!authAccess) return; 18 | 19 | let discordId = await GetDiscordIdFromToken(authAccess, res); 20 | if (!discordId) return; 21 | 22 | const bodyCheck = checkBody(body); 23 | if (bodyCheck !== true) { 24 | BuildResponse(res, HttpStatus.MalformedRequest, `Query string "${bodyCheck}" not provided or malformed`); 25 | return; 26 | } 27 | 28 | var callerUser = await GetGuildUser(discordId); 29 | if (!callerUser) return; 30 | 31 | const user = await (body.userId ? 32 | User.findOne({ 33 | where: { id: body.userId } 34 | }) : 35 | User.findOne({ 36 | where: { discordId: body.discordId?.toString() ?? -1 } 37 | }) 38 | ).catch(err => { BuildResponse(res, HttpStatus.NotFound, "User not found") }); 39 | 40 | if (!user) { 41 | BuildResponse(res, HttpStatus.BadRequest, `User isn't registered with the UWP Community.`); 42 | return; 43 | } 44 | 45 | const contributorRole = await GetRoleByName(body.role); 46 | if (!contributorRole) { 47 | BuildResponse(res, HttpStatus.BadRequest, `Role not found.`); 48 | return; 49 | } 50 | 51 | var collaborators = await GetProjectCollaborators(body.projectId); 52 | 53 | const isOwner = collaborators.find(collaborator => collaborator.isOwner)?.discordId == user.discordId; 54 | const isCollaborator = collaborators.find(collaborator => collaborator.discordId == user.discordId); 55 | const isMod = callerUser.roles.cache.find(i => i.name.toLowerCase() == "mod" || i.name.toLowerCase() == "admin"); 56 | 57 | const isLead = isCollaborator && await UserHasDbRole(body.projectId, user.discordId, "Lead"); 58 | const isSupport = isCollaborator && await UserHasDbRole(body.projectId, user.discordId, "Support"); 59 | const isDev = isCollaborator && await UserHasDbRole(body.projectId, user.discordId, "Developer"); 60 | 61 | const userCanModify = isOwner || isLead || isSupport || isDev || isMod || user.discordId?.toString() == discordId; 62 | const userCanModifyDevs = isOwner || isLead || isSupport || isMod; 63 | const userCanModifyLead = isOwner || isMod; 64 | 65 | if (!userCanModify || contributorRole.name == "Developer" && !userCanModifyDevs || contributorRole.name == "Lead" && !userCanModifyLead) { 66 | BuildResponse(res, HttpStatus.Unauthorized, "You don't have permission to modify this project"); 67 | return; 68 | } 69 | 70 | if (isOwner) { 71 | BuildResponse(res, HttpStatus.BadRequest, `Project owners cannot be removed. To remove the owner, transfer ownership or delete the project.`); 72 | return; 73 | } 74 | 75 | const guildMembers = await GetGuildMembers(); 76 | var discordUser = guildMembers?.find(m => m.user.id === user.discordId); 77 | if (!discordUser) { 78 | BuildResponse(res, HttpStatus.InternalServerError, `User data found but user isn't in the discord server. Please contact an administrator to correct the issue.`); 79 | return; 80 | } 81 | 82 | const RelevantUserProjects: void | UserProject[] = await UserProject.findAll({ where: { projectId: body.projectId, userId: user.id, roleId: contributorRole.id } }).catch((err: IRequestPromiseReject) => { BuildResponse(res, err.status, err.reason) }); 83 | if (!RelevantUserProjects) 84 | return; 85 | 86 | const project = await getProjectById(body.projectId, res); 87 | 88 | const desiredRole: void | Role = await getRoleForProject(project, body.role).catch((err) => { BuildResponse(res, HttpStatus.NotFound, err) }); 89 | if (!desiredRole) return; 90 | 91 | await Promise.all([ 92 | safeRemoveRole(desiredRole, discordUser), 93 | RelevantUserProjects[0].destroy(), 94 | ]) 95 | .catch(err => genericServerError(err, res)) 96 | .then(() => BuildResponse(res, HttpStatus.Success)); 97 | }; 98 | 99 | export function getProjectById(projectId: number, res: Response): Promise { 100 | return new Promise(async (resolve, reject) => { 101 | 102 | var projects: IProject[] = await getAllProjects().catch(err => ResponsePromiseReject("Internal server error: " + err, HttpStatus.InternalServerError, reject)); 103 | 104 | if (!projects) 105 | return; 106 | 107 | projects = projects.filter(x => x.id === projectId); 108 | 109 | resolve(projects[0]); 110 | }); 111 | } 112 | async function UserHasDbRole(projectId: number, userDiscordId: string, roleName: string): Promise { 113 | const role = await GetRoleByName(roleName); 114 | if (!role) 115 | return false; 116 | 117 | const user = await getUserByDiscordId(userDiscordId); 118 | if (!user) 119 | return false; 120 | 121 | const projects = await GetProjectsByUserId(user.id); 122 | if (!projects || projects.length === 0) 123 | return false; 124 | 125 | const relevantProject = projects.filter(x => x.id == projectId)[0]; 126 | 127 | if (!relevantProject) 128 | return false; 129 | 130 | const roleExists = await UserProject.findOne({ where: { roleId: role.id, userId: user.id, projectId: relevantProject.id } }); 131 | 132 | return !!roleExists; 133 | } 134 | 135 | /** 136 | * @returns Role if a discord role is found. Undefined if no matching discord role is found. Null if the role was never searched for (usually because of some handled error). 137 | */ 138 | async function getRoleForProject(project: IProject, roleName: string): Promise { 139 | const roles = await GetRoles(); 140 | 141 | // Should be a regex that captures one group (the app name) 142 | let appNameInRoleRegex: RegExp; 143 | 144 | switch (roleName) { 145 | case "tester": 146 | case "Beta Tester": 147 | appNameInRoleRegex = /Beta Tester \((.+)\)/; 148 | break; 149 | case "translator": 150 | case "Translator": 151 | appNameInRoleRegex = /Translator \((.+)\)/; 152 | break; 153 | case "dev": 154 | case "Dev": 155 | case "Developer": 156 | appNameInRoleRegex = /(.+) Dev/; 157 | break; 158 | case "advocate": 159 | case "Advocate": 160 | appNameInRoleRegex = /(.+) Advocate/; 161 | break; 162 | case "support": 163 | case "Support": 164 | appNameInRoleRegex = /(.+) Support/; 165 | break; 166 | case "lead": 167 | case "Lead": 168 | appNameInRoleRegex = /(.+) Lead/; 169 | break; 170 | case "patreon": 171 | case "Patreon": 172 | appNameInRoleRegex = /(.+) Patreon/; 173 | break; 174 | default: 175 | return Promise.reject(`${roleName} is not a valid role type. Expected \`tester\`, \`translator\`, \`dev\`, \`advocate\`, \`support\`, \`lead\`, or \`patreon\``); 176 | } 177 | 178 | const matchedRoles = roles?.filter(role => { 179 | const matchingRoles = Array.from(role.name.matchAll(appNameInRoleRegex)); 180 | if (!matchingRoles || matchingRoles.length === 0) { 181 | return; 182 | } 183 | 184 | const appName = matchingRoles[0][1]?.toLowerCase(); 185 | return project.appName.toLowerCase().includes(appName); 186 | }) 187 | 188 | if (!matchedRoles || matchedRoles.length == 0) { 189 | return Promise.reject(`No ${roleName} role was found for ${project.appName}.`); 190 | } 191 | 192 | return matchedRoles?.shift() ?? Promise.reject("Not found"); 193 | } 194 | 195 | function checkBody(body: IDeleteProjectCollaboratorRequestBody): true | string { 196 | if (!body.projectId) return "projectId"; 197 | 198 | if (!body.userId && !body.discordId) 199 | return "userId\" or \"discordId"; 200 | 201 | if (!body.role) return "role"; 202 | return true; 203 | } 204 | 205 | function safeRemoveRole(role: Role | undefined, discordUser: GuildMember) { 206 | if (role) 207 | discordUser.roles.remove(role); 208 | } 209 | 210 | interface IDeleteProjectCollaboratorRequestBody { 211 | projectId: number; 212 | userId?: number; 213 | discordId?: number; 214 | role: string; 215 | } 216 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { InitBot, bot } from "./common/helpers/discord"; 3 | import { InitDb, CreateMocks } from './common/sequalize'; 4 | import * as helpers from './common/helpers/generic'; 5 | import cors from "cors"; 6 | import { IBotCommandArgument } from "./models/types"; 7 | import { RefreshProjectCache } from "./models/Project"; 8 | import { TextChannel } from "discord.js"; 9 | 10 | /** 11 | * This file sets up API endpoints based on the current folder tree in Heroku. 12 | * 13 | * Here's how it works: 14 | * Consumable JS files named with an HTTP method (all lowercase) are handed the Request and Response parameters from ExpressJS 15 | * The path of the file is set up as the endpoint on the server, and is set up with the HTTP method indicated by the filename 16 | * 17 | * Example: 18 | * The file `./src/myapp/bugreport/post.js` is set up at `POST https://example.com/myapp/bugreport/` 19 | */ 20 | 21 | const express = require('express'), app = express(); 22 | const expressWs = require('express-ws')(app); 23 | 24 | const bodyParser = require('body-parser'); 25 | const glob = require('glob'); 26 | const swaggerUi = require('swagger-ui-express'); 27 | 28 | const PORT = process.env.PORT || 5000; 29 | const MOCK = process.argv.filter(val => val == 'mock').length > 0; 30 | 31 | app.use(cors()); 32 | app.use(bodyParser.urlencoded({ extended: true })); 33 | app.use(bodyParser.json()); 34 | app.use((req: Request, res: Response, next: NextFunction) => { 35 | // Website you wish to allow to connect 36 | res.setHeader('Access-Control-Allow-Origin', '*'); 37 | 38 | // Request methods you wish to allow 39 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE'); 40 | 41 | // Request headers you wish to allow 42 | res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type,authorization'); 43 | 44 | // Pass to next layer of middleware 45 | next(); 46 | }); 47 | 48 | InitDb().then(() => { 49 | RefreshProjectCache(); 50 | if (MOCK) CreateMocks() 51 | }); 52 | 53 | InitBot(); 54 | 55 | SetupAPI(); 56 | 57 | SetupBotScripts(); 58 | 59 | app.listen(PORT, (err: string) => { 60 | if (err) { 61 | console.error(`Error while setting up port ${PORT}:`, err); 62 | return; 63 | } 64 | console.log(`Ready, listening on port ${PORT}`); 65 | }); 66 | 67 | 68 | //#region Setup 69 | 70 | let HttpMethodsRegex = /((?:post|get|put|patch|delete|ws)+)(?:.js)/; 71 | 72 | function initModuleOnBotReady(module: any) { 73 | if (module.Initialize) 74 | bot.once('ready', module.Initialize); 75 | } 76 | 77 | function SetupAPI() { 78 | glob(__dirname + '/api/**/*.js', function (err: Error, result: string[]) { 79 | for (let filePath of result) { 80 | 81 | if (!filePath.includes("node_modules") && helpers.match(filePath, HttpMethodsRegex)) { 82 | let serverPath = filePath.replace(HttpMethodsRegex, "").replace("/app", "").replace("/api", "").replace("/build", ""); 83 | 84 | if (helpers.match(serverPath, /{(.+)}\/?$/)) { 85 | // Check paths with route params for sibling folders 86 | const folderPath = filePath.replace(/{.+}(.+)$/, "\/\*\/"); 87 | glob(folderPath, (err: Error, siblingDir: string[]) => { 88 | if (siblingDir.length > 1) throw new Error("Folder representing a route parameter cannot have sibling folders: " + folderPath); 89 | }); 90 | } 91 | 92 | // Reformat route params from folder-friendly to express spec 93 | serverPath = serverPath.replace(/{([^\/]+)}/g, ":$1"); 94 | 95 | if (helpers.DEVENV) serverPath = serverPath.replace(__dirname.replace(/\\/g, `/`).replace("/build", ""), ""); 96 | 97 | const method = helpers.match(filePath, HttpMethodsRegex); 98 | if (!method) continue; 99 | 100 | console.log(`Setting up ${filePath} as ${method.toUpperCase()} ${serverPath}`); 101 | 102 | switch (method) { 103 | case "post": 104 | app.post(serverPath, require(filePath)); 105 | break; 106 | case "get": 107 | app.get(serverPath, require(filePath)); 108 | break; 109 | case "put": 110 | app.put(serverPath, require(filePath)); 111 | break; 112 | case "patch": 113 | app.patch(serverPath, require(filePath)); 114 | break; 115 | case "delete": 116 | app.delete(serverPath, require(filePath)); 117 | break; 118 | case "ws": 119 | app.ws(serverPath, require(filePath)(expressWs, serverPath)); 120 | break; 121 | } 122 | } 123 | } 124 | }); 125 | 126 | const yaml = require('js-yaml'); 127 | const fs = require('fs'); 128 | 129 | // Get document, or throw exception on error 130 | try { 131 | const doc = yaml.safeLoad(fs.readFileSync('./src/api.yaml', 'utf8')); 132 | app.use('/__docs', swaggerUi.serve, swaggerUi.setup(doc)); 133 | app.get('/swagger.json', (req: Request, res: Response) => res.json(doc)); 134 | } catch (e) { 135 | console.log(e); 136 | } 137 | } 138 | 139 | async function SetupBotScripts() { 140 | await SetupBotCommands(); 141 | await SetupBotEvents(); 142 | } 143 | 144 | async function SetupBotCommands() { 145 | glob(`${__dirname}/bot/commands/*.js`, async (err: Error, result: string[]) => { 146 | for (let filePath of result) { 147 | const module = await import(filePath); 148 | if (!module.default) throw "No default export was defined in " + filePath; 149 | initModuleOnBotReady(module); 150 | 151 | const commandPrefix = helpers.match(filePath, /\/bot\/commands\/(.+).js/); 152 | if (!commandPrefix) return; 153 | 154 | bot.on('messageCreate', message => { 155 | // Message must be prefixed 156 | if (message.content.startsWith(`!${commandPrefix}`)) { 157 | 158 | message.content = message.content.replace('@here', ''); 159 | message.content = message.content.replace('@everyone', ''); 160 | 161 | if (message.mentions.everyone) 162 | return; // Don't allow mentioning everyone 163 | 164 | if (message.author?.bot) 165 | return; // ignore messages sent by bots. 166 | 167 | const argsRegexMatch = message.content.matchAll(/ (?:\/|-|--)([a-zA-Z1-9]+) (?:([\w\/\,\.:#!~\@\$\%\^&\*\(\)-_+=`\[\]\\\|\;\'\<\>]+)|\"([\w\s\/\,\.:#!~\@\$\%\^&\*\(\)-_+=`\[\]\\\|\;\'\<\>)]+)\")/gm); 168 | const argsMatch = Array.from(argsRegexMatch); 169 | let args: IBotCommandArgument[] = argsMatch.map(i => { return { name: i[1], value: i[2] || i[3] } }); 170 | 171 | let noArgsCommand = message.content; 172 | 173 | // In order to easily get the command parts, we first remove the arguments 174 | for (const argMatch of argsMatch) 175 | noArgsCommand = noArgsCommand.replace(argMatch[0], ""); 176 | 177 | const commandPartsRegexMatch = noArgsCommand.matchAll(/ \"(.+?)\"| (\S+)/g); 178 | const commandPartsMatch = Array.from(commandPartsRegexMatch); 179 | let commandParts: string[] = commandPartsMatch.map(i => i[1] || i[2]); 180 | 181 | // If a user was mentioned, add the discordId as an argument. 182 | // Only first user supported. 183 | if (message.mentions?.members) { 184 | var mentions = [...message.mentions.members.values()]; 185 | if (mentions && mentions.length > 0) { 186 | args.push({ 187 | name: "discordId", 188 | value: mentions[0].id 189 | }); 190 | } 191 | } 192 | 193 | module.default(message, commandParts, args); 194 | 195 | const peekArg = args.find(i => i.name == "peek"); 196 | if (peekArg) { 197 | var timeout = isNaN(peekArg.value as any) ? 5 : parseInt(peekArg.value as any); 198 | 199 | setTimeout(() => { 200 | (message.channel as TextChannel).messages.cache.find(x => x.author.id == bot.user?.id)?.delete(); 201 | message.delete(); 202 | }, timeout * 1000); 203 | } 204 | } 205 | }); 206 | } 207 | }); 208 | } 209 | 210 | async function SetupBotEvents() { 211 | glob(`${__dirname}/bot/events/*.js`, async (err: Error, result: string[]) => { 212 | for (let filePath of result) { 213 | const module = await import(filePath); 214 | if (!module.default) throw "No default export was defined in " + filePath; 215 | initModuleOnBotReady(module); 216 | 217 | const eventName = helpers.match(filePath, /\/bot\/events\/(.+).js/); 218 | if (!eventName) throw `Could not get event name from path (${filePath})`; 219 | bot.on(eventName, module.default); 220 | } 221 | }); 222 | } 223 | //#endregion 224 | -------------------------------------------------------------------------------- /src/api/projects/put.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User, { getUserByDiscordId } from "../../models/User" 3 | import Project, { getAllDbProjects, ProjectFieldsAreValid, RefreshProjectCache } from "../../models/Project"; 4 | import { validateAuthenticationHeader } from '../../common/helpers/generic'; 5 | import { IProject } from "../../models/types"; 6 | import { GetDiscordIdFromToken, GetGuildUser } from "../../common/helpers/discord"; 7 | import { BuildResponse, HttpStatus, ResponsePromiseReject, IRequestPromiseReject } from "../../common/helpers/responseHelper"; 8 | import { UserOwnsProject } from "../../models/UserProject"; 9 | import ProjectImage from "../../models/ProjectImage"; 10 | import ProjectFeature from "../../models/ProjectFeature"; 11 | import ProjectTag from "../../models/ProjectTag"; 12 | 13 | module.exports = async (req: Request, res: Response) => { 14 | const body = req.body as IProject; 15 | 16 | body.images == body.images ?? []; 17 | 18 | const authAccess = validateAuthenticationHeader(req, res); 19 | if (!authAccess) return; 20 | 21 | let discordId = await GetDiscordIdFromToken(authAccess, res); 22 | if (!discordId) return; 23 | 24 | const queryCheck = checkQuery(req.query); 25 | if (queryCheck !== true) { 26 | BuildResponse(res, HttpStatus.MalformedRequest, `Query string "${queryCheck}" not provided or malformed`); 27 | return; 28 | } 29 | 30 | const bodyCheck = checkIProject(body); 31 | if (bodyCheck !== true) { 32 | BuildResponse(res, HttpStatus.MalformedRequest, `Parameter "${bodyCheck}" not provided or malformed`); 33 | return; 34 | } 35 | 36 | if (!await ProjectFieldsAreValid(body, res)) 37 | return; 38 | 39 | updateProject(body, req.query, discordId) 40 | .then(() => { 41 | BuildResponse(res, HttpStatus.Success, "Success"); 42 | RefreshProjectCache(); 43 | }) 44 | .catch((err: IRequestPromiseReject) => BuildResponse(res, err.status, err.reason)); 45 | }; 46 | 47 | function checkQuery(query: IPutProjectRequestQuery): true | string { 48 | if (!query.appName) return "appName"; 49 | 50 | return true; 51 | } 52 | function checkIProject(body: IProject): true | string { 53 | if (!body.appName) return "appName"; 54 | if (!body.description) return "description"; 55 | if (body.isPrivate === undefined) return "isPrivate"; 56 | if (body.awaitingLaunchApproval === undefined) return "awaitingLaunchApproval"; 57 | if (body.needsManualReview === undefined) return "needsManualReview"; 58 | 59 | return true; 60 | } 61 | 62 | function updateProject(projectUpdateRequest: IProject, query: IPutProjectRequestQuery, discordId: string): Promise { 63 | return new Promise(async (resolve, reject) => { 64 | let DBProjects = await getAllDbProjects(); 65 | 66 | var appName = decodeURIComponent(query.appName); 67 | 68 | DBProjects = DBProjects.filter(x => x.appName === appName); 69 | 70 | if (DBProjects.length === 0) { 71 | ResponsePromiseReject(`Project with name "${appName}" could not be found.`, HttpStatus.NotFound, reject); 72 | return; 73 | } 74 | 75 | const guildMember = await GetGuildUser(discordId); 76 | const isMod = guildMember && [...guildMember.roles.cache.values()].filter(role => role.name.toLowerCase() === "mod" || role.name.toLowerCase() === "admin").length > 0; 77 | 78 | const user: User | null = await getUserByDiscordId(discordId); 79 | if (!user) { 80 | ResponsePromiseReject("User not found", HttpStatus.NotFound, reject); 81 | return; 82 | } 83 | 84 | const userOwnsProject: boolean = await UserOwnsProject(user, DBProjects[0]); 85 | const userCanModify = userOwnsProject || isMod; 86 | 87 | if (!userCanModify) { 88 | ResponsePromiseReject("Unauthorized user", HttpStatus.Unauthorized, reject); 89 | return; 90 | } 91 | 92 | const shouldUpdateManualReview: boolean = DBProjects[0].needsManualReview !== projectUpdateRequest.needsManualReview; 93 | 94 | const shouldUpdateAwaitingLaunch: boolean = DBProjects[0].awaitingLaunchApproval !== projectUpdateRequest.awaitingLaunchApproval; 95 | 96 | const DbProjectData: Partial | void = await StdToDbModal_IPutProjectsRequestBody(projectUpdateRequest, discordId, shouldUpdateManualReview, shouldUpdateAwaitingLaunch).catch(reject); 97 | 98 | if (DbProjectData) { 99 | await DBProjects[0].update(DbProjectData) 100 | .then(() => updateImages(DBProjects, projectUpdateRequest)) 101 | .then(() => updateFeatures(DBProjects, projectUpdateRequest)) 102 | .then(() => updateTags(DBProjects, projectUpdateRequest)) 103 | .catch(error => reject({ status: HttpStatus.InternalServerError, reason: `Internal server error: ${error}` })) 104 | } 105 | 106 | resolve(); 107 | }); 108 | } 109 | 110 | function updateImages(DBProjects: Project[], projectUpdateRequest: IProject) { 111 | return new Promise(async (resolve, reject) => { 112 | 113 | // The images in the DB should match those sent in this request 114 | const existingDbImages = await ProjectImage.findAll({ where: { projectId: DBProjects[0].id } }); 115 | 116 | projectUpdateRequest.images = projectUpdateRequest.images ?? []; 117 | 118 | if (existingDbImages) { 119 | // Remove images from DB that exist in DB but don't exist in req 120 | for (let image of existingDbImages) { 121 | if (projectUpdateRequest.images.includes(image.imageUrl) == false) { 122 | await image.destroy(); 123 | } 124 | } 125 | 126 | var existingDbImageUrls = existingDbImages.map(x => x.imageUrl); 127 | 128 | // Create images in the DB that exist in req but not DB 129 | for (let url of projectUpdateRequest.images) { 130 | if (url.length == 0 || url.length > 300) 131 | continue; 132 | 133 | if (existingDbImageUrls.includes(url) === false) { 134 | await ProjectImage.create( 135 | { 136 | projectId: DBProjects[0].id, 137 | imageUrl: url 138 | }).catch(err => { 139 | console.log(err); 140 | reject(err); 141 | }); 142 | } 143 | } 144 | 145 | resolve(); 146 | } 147 | }); 148 | } 149 | 150 | function updateFeatures(DBProjects: Project[], projectUpdateRequest: IProject) { 151 | return new Promise(async (resolve, reject) => { 152 | 153 | // The features in the DB should match those sent in this request 154 | const existingDbFeatureRows = await ProjectFeature.findAll({ where: { projectId: DBProjects[0].id } }); 155 | 156 | projectUpdateRequest.features = projectUpdateRequest.features ?? []; 157 | 158 | if (existingDbFeatureRows) { 159 | // Remove features from DB that exist in DB but don't exist in req 160 | for (let feature of existingDbFeatureRows) { 161 | if (projectUpdateRequest.features.includes(feature.feature) == false) { 162 | await feature.destroy(); 163 | } 164 | } 165 | 166 | var existingDbFeatures = existingDbFeatureRows.map(x => x.feature); 167 | 168 | // Create features in the DB that exist in req but not DB 169 | for (let feature of projectUpdateRequest.features) { 170 | if (feature.length == 0 || feature.length > 240) 171 | continue; 172 | 173 | if (existingDbFeatures.includes(feature) === false) { 174 | await ProjectFeature.create( 175 | { 176 | projectId: DBProjects[0].id, 177 | feature: feature, 178 | }).catch(err => { 179 | console.log(err); 180 | reject(err); 181 | }); 182 | } 183 | } 184 | } 185 | 186 | resolve(); 187 | }); 188 | } 189 | 190 | function updateTags(DBProjects: Project[], projectUpdateRequest: IProject) { 191 | return new Promise(async (resolve, reject) => { 192 | 193 | // The tags in the DB should match those sent in this request 194 | const existingDbProjectTags = await ProjectTag.findAll({ where: { projectId: DBProjects[0].id } }); 195 | 196 | projectUpdateRequest.tags = projectUpdateRequest.tags ?? []; 197 | 198 | if (existingDbProjectTags) { 199 | // Remove tags from DB that exist in DB but don't exist in req 200 | for (let tag of existingDbProjectTags) { 201 | if ((projectUpdateRequest.tags.filter(x => x.id == tag.id).length > 0) == false) { 202 | await tag.destroy(); 203 | } 204 | } 205 | 206 | // Create tags in the DB that exist in req but not DB 207 | for (let tag of projectUpdateRequest.tags) { 208 | if (!tag.id) 209 | continue; 210 | 211 | if ((existingDbProjectTags.filter(x => x.id == tag.id).length > 0) === false) { 212 | await ProjectTag.create( 213 | { 214 | projectId: DBProjects[0].id, 215 | tagId: tag.id, 216 | }) 217 | .catch(err => { 218 | console.log(err); 219 | reject(err); 220 | }); 221 | } 222 | } 223 | } 224 | 225 | resolve(); 226 | }); 227 | } 228 | 229 | export function StdToDbModal_IPutProjectsRequestBody(projectData: IProject, discordId: string, shouldUpdateManualReview: boolean, shouldUpdateAwaitingLaunch: boolean): Promise> { 230 | return new Promise(async (resolve, reject) => { 231 | const updatedProject = projectData as IProject; 232 | 233 | const user = await getUserByDiscordId(discordId).catch(reject); 234 | if (!user) { 235 | ResponsePromiseReject("User not found", HttpStatus.NotFound, reject); 236 | return; 237 | }; 238 | 239 | const updatedDbProjectData: Partial = { appName: projectData.appName }; 240 | 241 | // Doing it this way allows us to only update fields that are supplied, without overwriting required fields 242 | if (updatedProject.description) updatedDbProjectData.description = updatedProject.description; 243 | if (updatedProject.category) updatedDbProjectData.category = updatedProject.category; 244 | if (updatedProject.isPrivate !== undefined) updatedDbProjectData.isPrivate = updatedProject.isPrivate; 245 | if (updatedProject.downloadLink) updatedDbProjectData.downloadLink = updatedProject.downloadLink; 246 | if (updatedProject.githubLink) updatedDbProjectData.githubLink = updatedProject.githubLink; 247 | if (updatedProject.externalLink) updatedDbProjectData.externalLink = updatedProject.externalLink; 248 | if (updatedProject.heroImage) updatedDbProjectData.heroImage = updatedProject.heroImage; 249 | if (updatedProject.appIcon) updatedDbProjectData.appIcon = updatedProject.appIcon; 250 | if (updatedProject.awaitingLaunchApproval !== undefined) updatedDbProjectData.awaitingLaunchApproval = updatedProject.awaitingLaunchApproval; 251 | if (updatedProject.needsManualReview !== undefined) updatedDbProjectData.needsManualReview = updatedProject.needsManualReview; 252 | if (updatedProject.lookingForRoles) updatedDbProjectData.lookingForRoles = JSON.stringify(updatedProject.lookingForRoles); 253 | 254 | const guildMember = await GetGuildUser(discordId); 255 | 256 | const isMod = guildMember && [...guildMember.roles.cache.values()].filter(role => role.name.toLowerCase() === "mod").length > 0; 257 | if (shouldUpdateManualReview) { 258 | if (!isMod) { 259 | ResponsePromiseReject("User has insufficient permissions", HttpStatus.Unauthorized, reject); 260 | return; 261 | } 262 | } 263 | 264 | resolve(updatedDbProjectData); 265 | }); 266 | } 267 | 268 | interface IPutProjectRequestQuery { 269 | /** @summary The app name that's being modified */ 270 | appName: string; 271 | } -------------------------------------------------------------------------------- /src/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: UWP Community API 4 | description: API for the UWP community discord API. 5 | version: 1.0.0 6 | 7 | servers: 8 | - url: https://uwpcommunity-site-backend.herokuapp.com 9 | description: Main (production) server 10 | - url: http://localhost:5000 11 | description: Local server 12 | 13 | paths: 14 | /: 15 | get: 16 | description: Root path, redirects to homepage on github pages 17 | responses: 18 | 302: 19 | description: Redirect 20 | 21 | /launch/participants: 22 | get: 23 | description: Returns the participant projects for a launch years 24 | parameters: 25 | - name: year 26 | description: Year to get projects for. 27 | in: query 28 | required: true 29 | schema: 30 | type : integer 31 | format: int64 32 | responses: 33 | 422: 34 | $ref: '#/components/responses/MalformedRequest' 35 | 36 | 200: 37 | description: List of Projects 38 | content: 39 | application/json: 40 | schema: 41 | type: array 42 | items: 43 | $ref: '#/components/schemas/Project' 44 | 45 | /projects/: 46 | get: 47 | description: Gets a list of projects, either for a user or across all users 48 | parameters: 49 | - name: accessToken 50 | description: Access token to authenticate with Discord 51 | in: query 52 | schema: 53 | type: string 54 | responses: 55 | 500: 56 | $ref: '#/components/responses/ServerError' 57 | 200: 58 | description: List of Projects 59 | content: 60 | application/json: 61 | schema: 62 | type: array 63 | items: 64 | $ref: '#/components/schemas/IProject' 65 | 66 | post: 67 | description: Creates a new project 68 | parameters: 69 | - name: accessToken 70 | description: Access token to authenticate with Discord 71 | in: query 72 | schema: 73 | type: string 74 | requestBody: 75 | required: true 76 | content: 77 | application/json: 78 | schema: 79 | $ref: '#/components/schemas/IProject' 80 | responses: 81 | 200: 82 | $ref: '#/components/responses/Success' 83 | 422: 84 | $ref: '#/components/responses/MalformedRequest' 85 | 401: 86 | $ref: '#/components/responses/InvalidToken' 87 | 500: 88 | $ref: '#/components/responses/ServerError' 89 | 90 | put: 91 | description: Edits an existing project 92 | parameters: 93 | - name: accessToken 94 | description: Access token to authenticate with Discord 95 | in: query 96 | schema: 97 | type: string 98 | requestBody: 99 | required: true 100 | content: 101 | application/json: 102 | schema: 103 | $ref: '#/components/schemas/IProject' 104 | responses: 105 | 200: 106 | $ref: '#/components/responses/Success' 107 | 422: 108 | $ref: '#/components/responses/MalformedRequest' 109 | 401: 110 | $ref: '#/components/responses/InvalidToken' 111 | 500: 112 | $ref: '#/components/responses/ServerError' 113 | 114 | delete: 115 | description: Removes a project by title 116 | parameters: 117 | - name: accessToken 118 | description: Access token to authenticate with the Discord api 119 | in: query 120 | required: true 121 | schema: 122 | type: string 123 | - name: appName 124 | description: The app name to remove from the database 125 | in: query 126 | required: true 127 | schema: 128 | type: string 129 | responses: 130 | 200: 131 | $ref: '#/components/responses/Success' 132 | 422: 133 | $ref: '#/components/responses/MalformedRequest' 134 | 401: 135 | $ref: '#/components/responses/InvalidToken' 136 | 500: 137 | $ref: '#/components/responses/ServerError' 138 | 139 | /signin/redirect/: 140 | get: 141 | description: Redirects the user to the community homepage on successful request 142 | parameters: 143 | - name: code 144 | description: Discord authorization code. 145 | in: query 146 | required: true 147 | schema: 148 | type: string 149 | responses: 150 | 422: 151 | $ref: '#/components/responses/MalformedRequest' 152 | 153 | 302: 154 | description: Redirects to homepage 155 | 156 | /signin/refresh/: 157 | get: 158 | description: Refreshes the discord authorization given a refresh token from the last authorization 159 | parameters: 160 | - name: refreshToken 161 | description: Discord refresh token. 162 | in: query 163 | required: true 164 | schema: 165 | type: string 166 | responses: 167 | 422: 168 | $ref: '#/components/responses/MalformedRequest' 169 | 200: 170 | description: Discord API response 171 | content: 172 | application/json: 173 | schema: 174 | $ref: '#/components/schemas/DiscordResponse' 175 | 176 | /user/: 177 | get: 178 | description: Gets the currently logged in user 179 | parameters: 180 | - name: accessToken 181 | description: Access token to authenticate with Discord 182 | in: query 183 | schema: 184 | type: string 185 | responses: 186 | 404: 187 | $ref: '#/components/responses/UserNotFound' 188 | 422: 189 | $ref: '#/components/responses/MalformedRequest' 190 | 500: 191 | $ref: '#/components/responses/ServerError' 192 | 200: 193 | description: Current user 194 | content: 195 | application/json: 196 | schema: 197 | $ref: '#/components/schemas/User' 198 | 199 | post: 200 | description: Creates a new user 201 | parameters: 202 | - name: accessToken 203 | description: Access token to authenticate with Discord 204 | in: query 205 | schema: 206 | type: string 207 | requestBody: 208 | required: true 209 | content: 210 | application/json: 211 | schema: 212 | $ref: '#/components/schemas/User' 213 | responses: 214 | 200: 215 | $ref: '#/components/responses/Success' 216 | 422: 217 | $ref: '#/components/responses/MalformedRequest' 218 | 401: 219 | $ref: '#/components/responses/InvalidToken' 220 | 500: 221 | $ref: '#/components/responses/ServerError' 222 | 223 | put: 224 | description: Edits an existing user 225 | parameters: 226 | - name: accessToken 227 | description: Access token to authenticate with Discord 228 | in: query 229 | schema: 230 | type: string 231 | requestBody: 232 | required: true 233 | content: 234 | application/json: 235 | schema: 236 | $ref: '#/components/schemas/IProject' 237 | responses: 238 | 200: 239 | $ref: '#/components/responses/Success' 240 | 401: 241 | $ref: '#/components/responses/InvalidToken' 242 | 404: 243 | $ref: '#/components/responses/UserNotFound' 244 | 422: 245 | $ref: '#/components/responses/MalformedRequest' 246 | 500: 247 | $ref: '#/components/responses/ServerError' 248 | 249 | delete: 250 | description: Removes a user given their access token. This also removes *ALL* of their projects 251 | parameters: 252 | - name: accessToken 253 | description: Access token of the user to delete 254 | in: query 255 | required: true 256 | schema: 257 | type: string 258 | responses: 259 | 200: 260 | $ref: '#/components/responses/Success' 261 | 401: 262 | $ref: '#/components/responses/InvalidToken' 263 | 404: 264 | $ref: '#/components/responses/UserNotFound' 265 | 422: 266 | $ref: '#/components/responses/MalformedRequest' 267 | 500: 268 | $ref: '#/components/responses/ServerError' 269 | 270 | components: 271 | responses: 272 | MalformedRequest: 273 | description: Malformed request 274 | content: 275 | application/json: 276 | schema: 277 | $ref: '#/components/schemas/Error' 278 | 279 | InvalidToken: 280 | description: Invalid Discord Token 281 | content: 282 | text/plain: 283 | schema: 284 | type: string 285 | example: Invalid accessToken 286 | 287 | Success: 288 | description: Success Message 289 | content: 290 | text/plain: 291 | schema: 292 | type: string 293 | example: Success 294 | 295 | ServerError: 296 | description: Internal server error 297 | content: 298 | text/plain: 299 | schema: 300 | type: string 301 | example: "Internal server error: error message" 302 | 303 | UserNotFound: 304 | description: 404 Error as the user didn't exist 305 | content: 306 | application/json: 307 | schema: 308 | $ref: '#/components/schemas/Error' 309 | 310 | schemas: 311 | Error: 312 | required: 313 | - "error" 314 | - "reason" 315 | properties: 316 | error: 317 | type: "string" 318 | example: "Malformed request" 319 | reason: 320 | type: "string" 321 | example: "Query string not provided or malformed" 322 | 323 | Project: 324 | required: 325 | - "id" 326 | - "appName" 327 | - "description" 328 | - "isPrivate" 329 | - "createdAt" 330 | - "updatedAt" 331 | - "downloadLink" 332 | - "githubLink" 333 | - "externalLink" 334 | - "launchId" 335 | - "userId" 336 | - "launch" 337 | - "user" 338 | properties: 339 | id: 340 | type: "number" 341 | appName: 342 | type: "string" 343 | description: 344 | type: "string" 345 | isPrivate: 346 | type: "boolean" 347 | createdAt: 348 | type: "string" 349 | updatedAt: 350 | type: "string" 351 | downloadLink: 352 | type: "string" 353 | githubLink: 354 | type: "string" 355 | externalLink: 356 | type: "string" 357 | launchId: 358 | type: "number" 359 | userId: 360 | type: "number" 361 | launch: 362 | required: 363 | - "id" 364 | - "year" 365 | - "createdAt" 366 | - "updatedAt" 367 | properties: 368 | id: 369 | type: "number" 370 | year: 371 | type: "number" 372 | createdAt: 373 | type: "string" 374 | updatedAt: 375 | type: "string" 376 | type: "object" 377 | user: 378 | required: 379 | - "id" 380 | - "name" 381 | - "email" 382 | - "discordId" 383 | - "createdAt" 384 | - "updatedAt" 385 | properties: 386 | id: 387 | type: "number" 388 | name: 389 | type: "string" 390 | email: 391 | type: "string" 392 | discordId: 393 | type: "string" 394 | createdAt: 395 | type: "string" 396 | updatedAt: 397 | type: "string" 398 | type: "object" 399 | 400 | IProject: 401 | required: 402 | - "appName" 403 | - "description" 404 | - "isPrivate" 405 | properties: 406 | appName: 407 | type: "string" 408 | description: 409 | type: "string" 410 | isPrivate: 411 | type: "boolean" 412 | downloadLink: 413 | type: "string" 414 | githubLink: 415 | type: "string" 416 | externalLink: 417 | type: "string" 418 | launchId: 419 | type: "number" 420 | 421 | DiscordResponse: 422 | properties: 423 | access_token: 424 | type: "string" 425 | token_type: 426 | type: "string" 427 | expires_in: 428 | type: "number" 429 | refresh_token: 430 | type: "string" 431 | scope: 432 | type: "string" 433 | 434 | User: 435 | required: 436 | - "name" 437 | properties: 438 | id: 439 | type: "number" 440 | name: 441 | type: "string" 442 | email: 443 | type: "string" 444 | discordId: 445 | type: "string" 446 | createdAt: 447 | type: "string" 448 | updatedAt: 449 | type: "string" 450 | -------------------------------------------------------------------------------- /src/models/Project.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreatedAt, Model, Table, UpdatedAt, PrimaryKey, AutoIncrement, DataType, BelongsToMany, HasMany } from 'sequelize-typescript'; 2 | import User, { getUserByDiscordId } from './User'; 3 | import UserProject, { DbToStdModal_UserProject, GetProjectCollaborators } from './UserProject'; 4 | import { IProject, IProjectCollaborator } from './types'; 5 | import { isUrl, levenshteinDistance, match } from '../common/helpers/generic'; 6 | import Tag, { DbToStdModal_Tag } from './Tag'; 7 | import ProjectTag from './ProjectTag'; 8 | import fs from 'fs'; 9 | import { BuildResponse, HttpStatus, ResponsePromiseReject } from '../common/helpers/responseHelper'; 10 | import { Response } from 'express'; 11 | import { GetGuildUser } from '../common/helpers/discord'; 12 | import ProjectImage from './ProjectImage'; 13 | import fetch from 'node-fetch'; 14 | 15 | @Table 16 | export default class Project extends Model { 17 | @PrimaryKey 18 | @AutoIncrement 19 | @Column(DataType.INTEGER) 20 | declare id: number; 21 | 22 | @Column 23 | appName!: string; 24 | 25 | @Column 26 | description!: string; 27 | 28 | @Column 29 | isPrivate!: boolean; 30 | 31 | @Column 32 | downloadLink!: string; 33 | 34 | @Column 35 | githubLink!: string; 36 | 37 | @Column 38 | externalLink!: string; 39 | 40 | @Column 41 | awaitingLaunchApproval!: boolean; 42 | 43 | @Column 44 | needsManualReview!: boolean; 45 | 46 | @Column 47 | lookingForRoles!: string; 48 | 49 | @Column 50 | heroImage!: string; 51 | 52 | @Column 53 | appIcon!: string; 54 | 55 | @Column 56 | accentColor!: string; 57 | 58 | @BelongsToMany(() => User, () => UserProject) 59 | users?: User[]; 60 | 61 | @BelongsToMany(() => Tag, () => ProjectTag) 62 | tags?: Tag[]; 63 | 64 | @HasMany(() => UserProject) 65 | userProjects!: UserProject[]; 66 | 67 | @Column 68 | category!: string; 69 | 70 | @CreatedAt 71 | @Column 72 | declare createdAt: Date; 73 | 74 | @UpdatedAt 75 | @Column 76 | declare updatedAt: Date; 77 | } 78 | 79 | export function isExistingProject(appName: string): Promise { 80 | return new Promise((resolve, reject) => { 81 | Project.findAll({ 82 | where: { appName: appName } 83 | }).then(projects => { 84 | resolve(projects.length > 0); 85 | }).catch(reject) 86 | }); 87 | } 88 | 89 | export async function getProjectsByDiscordId(discordId: string): Promise { 90 | let projects = await getAllDbProjects().catch(x => Promise.reject(x)); 91 | 92 | projects = projects.filter(x => x.users?.filter(x => x.discordId == discordId).length ?? 0 > 0); 93 | 94 | if (!projects) 95 | Promise.reject("User not found"); 96 | 97 | return projects; 98 | } 99 | 100 | export function getOwnedProjectsByDiscordId(discordId: string): Promise { 101 | return new Promise(async (resolve, reject) => { 102 | // Get user by id 103 | const user = await getUserByDiscordId(discordId).catch(reject); 104 | if (!user) 105 | return; 106 | 107 | // Get user projects with this id 108 | const userProjects = await UserProject.findAll({ where: { userId: user.id, isOwner: true } }).catch(reject); 109 | if (!userProjects) 110 | return; 111 | 112 | const results: Project[] = []; 113 | // Get projects 114 | for (let userProject of userProjects) { 115 | const project = await Project.findOne({ where: { id: userProject.projectId } }).catch(reject); 116 | if (project) { 117 | results.push(project); 118 | } 119 | } 120 | 121 | resolve(results); 122 | }); 123 | } 124 | 125 | export interface ISimilarProjectMatch { 126 | distance: number; 127 | appName: string; 128 | } 129 | 130 | export let IsRefreshingCache = false; 131 | export let IsCacheInitialized = false; 132 | 133 | export async function RefreshProjectCache() { 134 | IsCacheInitialized = false; 135 | IsRefreshingCache = true; 136 | 137 | await getAllProjects(); 138 | 139 | IsRefreshingCache = false; 140 | IsCacheInitialized = true; 141 | } 142 | 143 | export async function ProjectFieldsAreValid(project: IProject, res: Response): Promise { 144 | 145 | // Make sure download link is a valid URL 146 | if (project.downloadLink && !isUrl(project.downloadLink)) { 147 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid downloadLink"); 148 | return false; 149 | } 150 | 151 | // Make sure github link is a valid URL 152 | if (project.githubLink && !isUrl(project.githubLink)) { 153 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid githubLink"); 154 | return false; 155 | } 156 | 157 | // Make sure external link is a valid URL 158 | if (project.externalLink && !isUrl(project.externalLink)) { 159 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid externalLink"); 160 | return false; 161 | } 162 | 163 | // Make sure hero image is an image URL or a microsoft store image 164 | if (project.heroImage && await isInvalidImage(project.heroImage)) { 165 | if (!project.heroImage.includes("https")) { 166 | BuildResponse(res, HttpStatus.MalformedRequest, "heroImage must be hosted on https"); 167 | return false; 168 | } 169 | 170 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid heroImage"); 171 | return false; 172 | } 173 | 174 | // Make sure images given are an image URL or a microsoft store image 175 | if (project.images) { 176 | for (let image of project.images) { 177 | if (await isInvalidImage(image)) { 178 | if (!image.includes("https")) { 179 | BuildResponse(res, HttpStatus.MalformedRequest, "Images must be hosted on https"); 180 | return false; 181 | } 182 | 183 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid image"); 184 | return false; 185 | } 186 | } 187 | } 188 | 189 | // Make sure app icon is an image URL or a microsoft store image 190 | if (project.appIcon && await isInvalidImage(project.appIcon)) { 191 | if (!project.appIcon.includes("https")) { 192 | BuildResponse(res, HttpStatus.MalformedRequest, "appIcon must be hosted on https"); 193 | return false; 194 | } 195 | 196 | BuildResponse(res, HttpStatus.MalformedRequest, "Invalid appIcon"); 197 | return false; 198 | } 199 | 200 | return true; 201 | } 202 | 203 | async function isInvalidImage(image: string): Promise { 204 | if (!image.includes("https")) 205 | return true; 206 | 207 | if (!isUrl(image)) 208 | return true; 209 | 210 | var res = await fetch(image); 211 | var contentType = res.headers.get("content-type"); 212 | 213 | if (!contentType) 214 | return true; 215 | 216 | return !contentType.includes("image/"); 217 | } 218 | 219 | export function nukeProject(appName: string, discordId: string): Promise { 220 | return new Promise((resolve, reject) => { 221 | getAllDbProjects() 222 | .then(async (allProjects) => { 223 | const projects = allProjects.filter(x => x.appName == appName); 224 | 225 | if (projects.length === 0) { ResponsePromiseReject(`Project with name "${appName}" could not be found.}`, HttpStatus.NotFound, reject); return; } 226 | if (projects.length > 1) { ResponsePromiseReject("More than one project with that name found. Contact a system administrator to fix the data duplication", HttpStatus.InternalServerError, reject); return; } 227 | 228 | const guildMember = await GetGuildUser(discordId); 229 | const isMod = guildMember && [...guildMember.roles.cache.filter(role => role.name.toLowerCase() === "mod" || role.name.toLowerCase() === "admin").values()].length > 0; 230 | 231 | const collaborators = await GetProjectCollaborators(projects[0].id); 232 | const userCanModify = collaborators.filter(x => x.isOwner && x.discordId == discordId).length > 0 || isMod; 233 | 234 | if (!userCanModify) { 235 | ResponsePromiseReject("Unauthorized user", HttpStatus.Unauthorized, reject); 236 | return; 237 | } 238 | 239 | const projectTags = await ProjectTag.findAll({ where: { projectId: projects[0].id } }).catch(err => ResponsePromiseReject(err, HttpStatus.InternalServerError, reject)) as ProjectTag[] | null; 240 | 241 | for (var tag of projectTags ?? []) { 242 | await tag.destroy(); 243 | } 244 | 245 | const projectImages = await ProjectImage.findAll({ where: { projectId: projects[0].id } }).catch(err => ResponsePromiseReject(err, HttpStatus.InternalServerError, reject)) as ProjectImage[] | null; 246 | 247 | for (let image of projectImages ?? []) { 248 | await image.destroy(); 249 | } 250 | 251 | const userProjects = await UserProject.findAll({ where: { projectId: projects[0].id } }).catch(err => ResponsePromiseReject(err, HttpStatus.InternalServerError, reject)) as UserProject[] | null; 252 | 253 | for (const userProject of userProjects ?? []) { 254 | await userProject.destroy(); 255 | } 256 | 257 | projects[0].destroy({ force: true }) 258 | .then(resolve) 259 | .catch(err => ResponsePromiseReject(err, HttpStatus.InternalServerError, reject)); 260 | 261 | }).catch(err => ResponsePromiseReject(err, HttpStatus.InternalServerError, reject)); 262 | }); 263 | } 264 | 265 | export async function getAllDbProjects(customWhere: any = undefined): Promise { 266 | const dbProjects = await Project.findAll({ 267 | include: [{ 268 | all: true 269 | }], 270 | where: customWhere, 271 | }).catch(Promise.reject); 272 | 273 | return (dbProjects); 274 | } 275 | 276 | export function getAllProjects(customWhere: any = undefined, cached: boolean = false): Promise { 277 | return new Promise(async (resolve, reject) => { 278 | if (cached && IsCacheInitialized) { 279 | var file = fs.readFileSync("./projects.json", {}).toString(); 280 | var cachedProjects = JSON.parse(file) as IProject[]; 281 | resolve(cachedProjects); 282 | } else { 283 | const DbProjects = await getAllDbProjects(customWhere).catch(reject); 284 | 285 | let projects: IProject[] = []; 286 | 287 | if (DbProjects) { 288 | for (let project of DbProjects) { 289 | let proj = DbToStdModal_Project(project); 290 | if (proj) { 291 | projects.push(proj); 292 | } 293 | } 294 | } 295 | 296 | fs.writeFileSync("./projects.json", JSON.stringify(projects), {}); 297 | 298 | IsCacheInitialized = true; 299 | 300 | resolve(projects); 301 | } 302 | }); 303 | } 304 | 305 | /** 306 | * @summary Looks through a list of projects to find the closest matching app name 307 | * @param projects Array of projects to look through 308 | * @param appName App name to match against 309 | * @returns Closest suitable match if found, otherwise undefined 310 | */ 311 | export function findSimilarProjectName(projects: Project[], appName: string, maxDistance: number = 7): string | undefined { 312 | let matches: ISimilarProjectMatch[] = []; 313 | 314 | // Calculate and store the distances of each possible match 315 | for (let project of projects) { 316 | matches.push({ distance: levenshteinDistance(project.appName, appName), appName: project.appName }); 317 | } 318 | 319 | const returnData = matches[0].appName + (matches.length > 1 ? " or " + matches[1].appName : ""); 320 | 321 | // Sort by closest match 322 | matches = matches.sort((first, second) => first.distance - second.distance); 323 | 324 | // If the difference is less than X characters, return a possible match. 325 | if (matches[0].distance <= maxDistance) return returnData; // 7 characters is just enough for a " (Beta)" label 326 | 327 | // If the difference is greater than 1/3 of the entire string, don't return as a similar app name 328 | if ((appName.length / 3) < matches[0].distance) return; 329 | 330 | return returnData; 331 | } 332 | 333 | //#region Converters 334 | /** @summary This converts the data model ONLY, and does not represent the actual data in the database */ 335 | export async function StdToDbModal_Project(project: Partial): Promise> { 336 | const dbProject: Partial = { 337 | category: project.category, 338 | appName: project.appName, 339 | description: project.description, 340 | isPrivate: project.isPrivate, 341 | downloadLink: project.downloadLink, 342 | githubLink: project.githubLink, 343 | externalLink: project.externalLink, 344 | awaitingLaunchApproval: project.awaitingLaunchApproval, 345 | needsManualReview: project.needsManualReview, 346 | heroImage: project.heroImage, 347 | appIcon: project.appIcon, 348 | accentColor: project.accentColor, 349 | lookingForRoles: JSON.stringify(project.lookingForRoles) 350 | }; 351 | return (dbProject); 352 | } 353 | 354 | export function DbToStdModal_Project(project: Project): IProject { 355 | const collaborators: (IProjectCollaborator | undefined)[] = project.userProjects?.map(DbToStdModal_UserProject); 356 | 357 | const stdProject: IProject = { 358 | id: project.id, 359 | appName: project.appName, 360 | description: project.description, 361 | isPrivate: project.isPrivate, 362 | downloadLink: project.downloadLink, 363 | githubLink: project.githubLink, 364 | externalLink: project.externalLink, 365 | collaborators: collaborators.filter(x => x != undefined) as IProjectCollaborator[], 366 | category: project.category, 367 | createdAt: project.createdAt, 368 | updatedAt: project.updatedAt, 369 | awaitingLaunchApproval: project.awaitingLaunchApproval, 370 | needsManualReview: project.needsManualReview, 371 | images: [], 372 | features: [], 373 | tags: project.tags?.map(DbToStdModal_Tag) ?? [], 374 | heroImage: project.heroImage, 375 | appIcon: project.appIcon, 376 | accentColor: project.accentColor, 377 | lookingForRoles: JSON.parse(project.lookingForRoles) 378 | }; 379 | 380 | return (stdProject); 381 | } 382 | //#endregion 383 | -------------------------------------------------------------------------------- /src/bot/commands/infraction.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel, Role, Guild, GuildMember, VoiceChannel, GuildEmojiRoleManager, ChannelType } from "discord.js"; 2 | import { IBotCommandArgument } from "../../models/types"; 3 | import { GetGuild, GetChannelByName } from "../../common/helpers/discord"; 4 | import { setInterval } from "timers"; 5 | 6 | let infractions: IInfraction[]; 7 | let infractionData: IInfractionData[] = []; 8 | let handleInfractionRemovalInterval: NodeJS.Timeout; 9 | 10 | let successfulInit: boolean = false; 11 | 12 | export async function Initialize() { 13 | const server = await GetGuild(); 14 | if (server) { 15 | const botChannel = await GetChannelByName("bot-stuff") as TextChannel; 16 | var mutedRole = server.roles.cache.find(i => i.name.toLowerCase() == "muted"); 17 | if (mutedRole == null) { 18 | console.error("Couldn't find muted role"); 19 | return; 20 | } 21 | 22 | await initExistingInfractionData(server); 23 | handleInfractionRemovalInterval = setInterval(handleInfractionRemoval, 15 * 1000, botChannel, mutedRole); 24 | setInterval(setupMutedChannelSettings, 15 * 1000, server, mutedRole); 25 | 26 | setupMutedChannelSettings(server, mutedRole); 27 | handleInfractionRemoval(botChannel, mutedRole); 28 | } 29 | } 30 | 31 | export default async (discordMessage: Message, commandParts: string[], args: IBotCommandArgument[]) => { 32 | if (!successfulInit) 33 | return; 34 | 35 | const server = await GetGuild(); 36 | if (!server) return; 37 | 38 | const infractionChannel = await GetChannelByName("infraction-log") as TextChannel; 39 | const botChannel = await GetChannelByName("bot-stuff") as TextChannel; 40 | const metaChannel = await GetChannelByName("meta") as TextChannel; 41 | 42 | if (!discordMessage.member?.roles.cache.find(i => i.name.toLowerCase() == "mod")) { 43 | return; 44 | } 45 | 46 | if (infractions == undefined) 47 | return; 48 | 49 | var mutedRole = server.roles.cache.find(i => i.name.toLowerCase() == "muted"); 50 | if (!mutedRole) { 51 | (discordMessage.channel as TextChannel).send(`Couldn't find muted role.`) 52 | return; 53 | } 54 | 55 | const messageLinkArg = args.find(i => i.name == "messageLink"), 56 | messageLink = messageLinkArg ? messageLinkArg.value : null; 57 | 58 | const discordIdArg = args.find(i => i.name == "discordId"), 59 | offenderDiscordId = discordIdArg ? discordIdArg.value : null; 60 | 61 | if (!messageLink && !offenderDiscordId) { 62 | (discordMessage.channel as TextChannel).send("A valid \`messageLink\` or \`discordId\` was not provided"); 63 | return; 64 | } 65 | 66 | const reasonArg = args.find(i => i.name == "reason"); 67 | if (!reasonArg) { 68 | (discordMessage.channel as TextChannel).send("No \`reason\` was specified"); 69 | return; 70 | } 71 | 72 | let originalMessage = ""; 73 | let member; 74 | 75 | if (offenderDiscordId) { 76 | 77 | member = await server.members.fetch(offenderDiscordId) 78 | 79 | } else if (messageLink) { 80 | 81 | const messageParts = messageLink.split("/"); 82 | 83 | if (!messageParts) { 84 | (discordMessage.channel as TextChannel).send(`Invalid link format`); 85 | return; 86 | } 87 | 88 | const serverId = messageParts[4]; 89 | const channelId = messageParts[5]; 90 | const messageId = messageParts[6]; 91 | 92 | if (!serverId || !channelId || !messageId) { 93 | (discordMessage.channel as TextChannel).send(`Missing data from link`); 94 | return; 95 | } 96 | 97 | if (serverId != server.id) { 98 | (discordMessage.channel as TextChannel).send("Link is from a different server"); 99 | return; 100 | } 101 | 102 | const relevantChannel = server.channels.cache.find(i => i.id == channelId) as TextChannel; 103 | if (!relevantChannel) { 104 | (discordMessage.channel as TextChannel).send("Channel not found"); 105 | return; 106 | } 107 | 108 | const relevantMessage = await relevantChannel.messages.fetch(messageId); 109 | if (!relevantMessage) { 110 | (discordMessage.channel as TextChannel).send("Message not found"); 111 | return; 112 | } 113 | 114 | relevantMessage.attachments.forEach(att => relevantMessage.content += "\n" + att.url); 115 | 116 | if (relevantMessage) { 117 | originalMessage = `Original message:\n> ${relevantMessage.content}`; 118 | } 119 | 120 | // Get previous recent infractions 121 | if (relevantMessage.member) 122 | member = await server.members.fetch(relevantMessage.member); 123 | } 124 | 125 | if (member == null) { 126 | botChannel.send(`Something went wrong. member was somehow null.`); 127 | return; 128 | } 129 | 130 | const memberInfraction: IInfraction = findInfractionFor(member); 131 | 132 | removeInfractionDataFor(member); 133 | 134 | // Only mute when the infraction isn't a warning 135 | if (memberInfraction.worstOffense != undefined) 136 | await member.roles.add(mutedRole); 137 | 138 | let infractionMsg; 139 | 140 | // User has no infractions 141 | if (memberInfraction.worstOffense == undefined) { 142 | metaChannel.send(`<@${member.id}>, you have been issued a warning.\n> Reason: ${reasonArg.value}\n${originalMessage}. \n Please remember to follow the rules in the future.\nThis is just a warning and will wear off in 3 days, but further rule violations will result in action`); 143 | infractionMsg = await infractionChannel.send(`${discordMessage.member.displayName} has issued a warning for <@${member.id}> for the following reason:\n> ${reasonArg.value}\n${originalMessage}`); 144 | } 145 | 146 | // If user has a warning and no strikes 147 | else if (memberInfraction.worstOffense.label == "Warned") { 148 | metaChannel.send(`<@${member.id}>, you have been issued a strike and a 1 week mute for the following reason:\n> ${reasonArg.value}\n${originalMessage}.\n Please remember to follow the rules in the future. \nThis strike will last for 3 weeks, and another infraction will result in a 3 week mute.`); 149 | infractionMsg = await infractionChannel.send(`${discordMessage.member.displayName} has issued Strike 1 for <@${member.id}> for the following reason:\n> ${reasonArg.value}\n${originalMessage}`); 150 | } 151 | 152 | // If user has 1 strike, and needs a 2nd 153 | else if (memberInfraction.worstOffense.label == "Strike 1") { 154 | metaChannel.send(`<@${member.id}>, you have been issued Strike 2 and a 3 week mute for the following reason:\n> ${reasonArg.value}\n${originalMessage}.\n Please remember to follow the rules in the future. \nThis strike will last for ~2 months (63 days), and another infraction will result in a 63 day mute.`); 155 | infractionMsg = await infractionChannel.send(`${discordMessage.member.displayName} has issued Strike 2 for <@${member.id}> for the following reason:\n> ${reasonArg.value}\n${originalMessage}`); 156 | } 157 | 158 | // If user has 2 strikes, and needs a 3rd 159 | else if (memberInfraction.worstOffense.label == "Strike 2") { 160 | metaChannel.send(`<@${member.id}>, you have been issued Strike 3 and a ~2 month (63 day) mute for the following reason:\n> ${reasonArg.value}\n${originalMessage}.\n Please remember to follow the rules in the future. \nThis strike will last for ~6 months (189 days), and another infraction will result in a 189 day mute.`); 161 | infractionMsg = await infractionChannel.send(`${discordMessage.member.id} has issued Strike 3 for <@${member.id}> for the following reason:\n> ${reasonArg.value}\n${originalMessage}`); 162 | } 163 | 164 | // If user has 3 strikes, needs a 4th 165 | else if (memberInfraction.worstOffense.label == "Strike 3") { 166 | metaChannel.send(`<@${member.id}>, you have been issued Strike 4 and a ~6 month (189 day) mute for the following reason:\n> ${reasonArg.value}\n${originalMessage}.\n Please remember to follow the rules in the future. \nThis strike will last for ~19 months (567 days). There is no greater punishment. Shame on you.`); 167 | infractionMsg = await infractionChannel.send(`${discordMessage.member.displayName} has issued Strike 4 for <@${member.id}> for the following reason:\n> ${reasonArg.value}\n${originalMessage}`); 168 | } 169 | 170 | else if (memberInfraction.worstOffense.label == "Strike 4") { 171 | metaChannel.send(`<@${member.id}>, you have been re-issued Strike 4 and a ~6 month (189 day) mute for the following reason:\n> ${reasonArg.value}\n${originalMessage}.\n Please remember to follow the rules in the future. \nThis strike will last for ~19 months (567 days). There is no greater punishment. Shame on you.`); 172 | infractionMsg = await infractionChannel.send(`${discordMessage.member.displayName} has re-issued Strike 4 for <@${member.id}> for the following reason:\n> ${reasonArg.value}\n${originalMessage}`); 173 | } 174 | 175 | infractionMsg?.pin(); 176 | 177 | member.roles.add(memberInfraction.nextInfraction.role); 178 | 179 | infractions.push({ 180 | member: member, 181 | worstOffense: memberInfraction.nextInfraction, 182 | nextInfraction: findNextInfraction(memberInfraction.nextInfraction), 183 | assignedAt: new Date(), 184 | message: infractionMsg, 185 | }); 186 | }; 187 | 188 | async function handleInfractionRemoval(botChannel: TextChannel, mutedRole: Role) { 189 | const warnedRole = infractionData.find(x => x.label == "Warned")?.role; 190 | const guild = await GetGuild(); 191 | if (!guild) return; 192 | 193 | for (let infrac of infractions) { 194 | if (!warnedRole) 195 | return; 196 | 197 | // These should never be undefined in the actual infractions data 198 | if (infrac.assignedAt == undefined || infrac.worstOffense == undefined) 199 | continue; 200 | 201 | // If the user no longer has the role, we can assume it was manually removed. 202 | if (!infrac.member.roles.cache.find(role => infrac.worstOffense?.role.id == role.id)) { 203 | 204 | botChannel.send(`User <@${infrac.member.id}> is internally recorded as having ${infrac.worstOffense?.label}, but doesn't have the corresponding role. Assuming manual role removal, cleaning up data.`); 205 | removeInfraction(infrac, warnedRole, guild); 206 | 207 | notifyRemoval(infrac, guild); 208 | continue; 209 | } 210 | 211 | // Unmute if needed. 212 | if (infrac.worstOffense.unmuteAfterDays && infrac.assignedAt < xDaysAgo(infrac.worstOffense.unmuteAfterDays)) { 213 | // Only unmute and send messages if the user is muted. 214 | if (infrac.member.roles.cache.find(x => x.id == mutedRole.id)) { 215 | infrac.member.send(`You have been unmuted in the ${guild.name} Discord server.`); 216 | infrac.member.roles.remove(mutedRole); 217 | 218 | botChannel.send(`<@${infrac.member.id}> has been unmuted`); 219 | } 220 | } 221 | 222 | // Remove infraction if needed. 223 | if (infrac.assignedAt < xDaysAgo(infrac.worstOffense.expiresAfterDays)) { 224 | removeInfraction(infrac, warnedRole, guild); 225 | notifyRemoval(infrac, guild); 226 | } 227 | } 228 | 229 | function removeInfraction(infrac: IInfraction, warnedRole: Role, guild: Guild) { 230 | if (infrac.worstOffense == undefined) 231 | return; 232 | 233 | infrac.member.roles.remove(infrac.worstOffense.role); 234 | 235 | infractions.splice(infractions.findIndex(x => x.member.id == infrac.member.id), 1); 236 | infrac.message?.unpin(); 237 | } 238 | 239 | function notifyRemoval(infrac: IInfraction, guild: Guild) { 240 | if (infrac.worstOffense == undefined) 241 | return; 242 | 243 | const infractionTypeLabel = infrac.worstOffense.label == "Warned" ? "warning" : "infraction"; 244 | 245 | infrac.member.send(`Your ${infractionTypeLabel} in the ${guild.name} Discord server has been removed.`); 246 | botChannel.send(`<@${infrac.member.id}>'s ${infractionTypeLabel} has been removed`); 247 | } 248 | } 249 | 250 | async function initExistingInfractionData(server: Guild) { 251 | const infractionChannel = await GetChannelByName("infraction-log") as TextChannel; 252 | 253 | const warnedRole = server.roles.cache.find(i => i.name.toLowerCase() == "warned"); 254 | const strike1Role = server.roles.cache.find(i => i.name.toLowerCase() == "strike 1"); 255 | const strike2Role = server.roles.cache.find(i => i.name.toLowerCase() == "strike 2"); 256 | const strike3Role = server.roles.cache.find(i => i.name.toLowerCase() == "strike 3"); 257 | const strike4Role = server.roles.cache.find(i => i.name.toLowerCase() == "strike 4"); 258 | 259 | if (!warnedRole || !strike1Role || !strike2Role || !strike3Role || !strike4Role) { 260 | const botChannel = await GetChannelByName("bot-stuff") as TextChannel; 261 | clearInterval(handleInfractionRemovalInterval) 262 | botChannel.send(`Unable to init existing infraction data. Missing a warned or strike role.`); 263 | return; 264 | } 265 | 266 | infractions = []; 267 | infractionData = [ 268 | { 269 | label: "Warned", 270 | role: warnedRole, 271 | expiresAfterDays: 14 // 2 weeks 272 | }, 273 | { 274 | label: "Strike 1", 275 | role: strike1Role, 276 | expiresAfterDays: 21, // 3 weeks 277 | unmuteAfterDays: 7 // 1 week 278 | }, 279 | { 280 | label: "Strike 2", 281 | role: strike2Role, 282 | expiresAfterDays: 63, // ~2 months 283 | unmuteAfterDays: 21 // 3 weeks 284 | }, 285 | { 286 | label: "Strike 3", 287 | role: strike3Role, 288 | expiresAfterDays: 189, // ~6 months 289 | unmuteAfterDays: 63 // ~2 months 290 | }, 291 | { 292 | label: "Strike 4", 293 | role: strike4Role, 294 | expiresAfterDays: 567, // ~19 months 295 | unmuteAfterDays: 189 // ~6 months 296 | } 297 | ]; 298 | 299 | // These must stay in order for findHighestInfractionRole to work properly 300 | const infractionRoles = [strike4Role, strike3Role, strike2Role, strike1Role, warnedRole]; 301 | 302 | // Build a list of all users' current infractions. We're using the infraction channel pins as a makeshift database. 303 | for (let message of (await infractionChannel.messages.fetchPinned())) { 304 | if (!message) 305 | continue; 306 | 307 | const mentionedMember = message[1].mentions.members?.first(); 308 | 309 | if (mentionedMember == null) 310 | continue; 311 | 312 | // If we already found an infraction for this user 313 | if (infractions.find(i => i.member.id == mentionedMember.id) != undefined) 314 | continue; 315 | 316 | // Find the worst offense they have in their roles. 317 | // The newer the offense is, the higher and more up to date it should be (if checking messages in the channel) 318 | 319 | const worstOffense = findHighestInfractionRole(mentionedMember, infractionRoles); 320 | 321 | if (worstOffense != undefined) { 322 | const strikeData = infractionData.find(i => i.role.id == worstOffense.id); 323 | 324 | infractions.push({ 325 | member: mentionedMember, 326 | worstOffense: strikeData, 327 | nextInfraction: findNextInfraction(strikeData), 328 | assignedAt: message[1].createdAt, 329 | message: message[1], 330 | }); 331 | } 332 | 333 | } 334 | 335 | successfulInit = true; 336 | } 337 | 338 | function setupMutedChannelSettings(server: Guild, mutedRole: Role) { 339 | server.channels.cache.forEach(channel => { 340 | if (channel.type === ChannelType.GuildText) { 341 | const mutedTextPermissions = channel.permissionOverwrites.resolve(mutedRole.id); 342 | 343 | // Check if permissions for muted role are missing or wrong 344 | if (!mutedTextPermissions || !mutedTextPermissions.deny.has(["SendMessages", "AddReactions"])) { 345 | channel.permissionOverwrites.create(mutedRole, { SendMessages: false, AddReactions: false }); 346 | } 347 | } else if (channel.type == ChannelType.GuildVoice) { 348 | const mutedVoicePermissions = channel.permissionOverwrites.resolve(mutedRole.id); 349 | if (!mutedVoicePermissions || !mutedVoicePermissions.deny.has(["Speak", "Stream"])) { 350 | channel.permissionOverwrites.create(mutedRole, { Speak: false, Stream: false }); 351 | } 352 | } 353 | }); 354 | } 355 | 356 | function findNextInfraction(infraction: IInfractionData | undefined): IInfractionData { 357 | if (infraction == undefined) return infractionData[0]; 358 | 359 | const currentIndex = infractionData.indexOf(infraction); 360 | if (currentIndex == infractionData.length - 1) 361 | return infractionData[infractionData.length - 1]; 362 | 363 | return infractionData[currentIndex + 1]; 364 | } 365 | 366 | function findHighestInfractionRole(member: GuildMember, roles: Role[]): Role | undefined { 367 | for (let role of roles) { 368 | if (member.roles.cache.find(i => role.id == i.id)) return role; 369 | } 370 | } 371 | 372 | function findInfractionFor(member: GuildMember): IInfraction { 373 | for (let infraction of infractions) 374 | if (infraction.member.id == member.id) 375 | return infraction; 376 | 377 | return { 378 | member: member, 379 | worstOffense: undefined, 380 | nextInfraction: infractionData[0], 381 | message: undefined, 382 | }; 383 | } 384 | 385 | function removeInfractionDataFor(member: GuildMember) { 386 | let index: number; 387 | 388 | while ((index = infractions.findIndex(x => x.member.id == member.id)) != -1) { 389 | infractions.splice(index, 1); 390 | } 391 | } 392 | 393 | 394 | function xDaysAgo(days: number) { 395 | var b = new Date(); 396 | b.setDate(b.getDate() - days); 397 | return b; 398 | } 399 | 400 | 401 | interface IInfraction { 402 | member: GuildMember; 403 | worstOffense: IInfractionData | undefined; 404 | nextInfraction: IInfractionData; 405 | message?: Message; 406 | assignedAt?: Date; 407 | } 408 | 409 | interface IInfractionData { 410 | label: string; 411 | role: Role; 412 | expiresAfterDays: number; 413 | unmuteAfterDays?: number; 414 | } 415 | --------------------------------------------------------------------------------