diff --git a/client/Terminal-00-Multiuser.user.js b/client/Terminal-00-Multiuser.user.js index bdcebab..deaefdf 100644 --- a/client/Terminal-00-Multiuser.user.js +++ b/client/Terminal-00-Multiuser.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name MultiProbe // @namespace https://*.angusnicneven.com/* -// @version 20241002.4 +// @version 20241003.0 // @description Probe with friends! // @author tgpholly // @match https://*.angusnicneven.com/* @@ -55,7 +55,7 @@ console.log("[MP] MultiProbe init"); 'use strict'; // Make sure to change the userscript version too!!!!!!!!!! - const USERSCRIPT_VERSION_RAW = "20241002.4"; + const USERSCRIPT_VERSION_RAW = "20241003.0"; const USERSCRIPT_VERSION = parseInt(USERSCRIPT_VERSION_RAW.replace(".", "")); if (!continueRunningScript) { @@ -889,9 +889,9 @@ mp_button { } case MessageType.BadgeUnlock: { - const badgeTitle = reader.readShortString(); - const badgeDescription = reader.readString(); - const badgeIconUrl = reader.readShortString(); + const badgeTitle = reader.readString16(); + const badgeDescription = reader.readString16(); + const badgeIconUrl = reader.readString16(); createBadgePopup(badgeTitle, badgeDescription, badgeIconUrl); } @@ -1095,7 +1095,7 @@ mp_button { popup.appendChild(badgeTitle); const badgeDescription = document.createElement("mp_text"); - badgeDescription.innerText = description; + badgeDescription.innerHTML = description; badgeDescription.style = "position:absolute;top:1.1rem;left:calc(1rem + 32px)"; popup.appendChild(badgeDescription); diff --git a/server/entities/User.ts b/server/entities/User.ts index 690ccf4..94001c4 100644 --- a/server/entities/User.ts +++ b/server/entities/User.ts @@ -10,6 +10,7 @@ export default class User { public PasswordSalt:string; public PasswordHash:string; public APIKey:string; + public HasUsedClient:boolean; public CreatedByUserId:number; public CreatedDatetime:Date; public LastModifiedByUserId?:number; @@ -18,31 +19,16 @@ export default class User { public DeletedDatetime?:Date; public IsDeleted:boolean; - public constructor(id?:number, userLevel?:UserLevel, username?:string, passwordSalt?:string, passwordHash?:string, apiKey?:string, createdByUserId?:number, createdDateTime?:Date, lastModifiedByUserId?:number, lastModifiedDatetime?:Date, deletedByUserId?:number, deletedDatetime?:Date, isDeleted?:boolean) { - if (typeof(id) == "number" && typeof(userLevel) == "number" && typeof(username) == "string" && typeof(passwordHash) == "string" && typeof(passwordSalt) == "string" && typeof(apiKey) == "string" && typeof(createdByUserId) == "number" && createdDateTime instanceof Date && typeof(lastModifiedByUserId) == "number" && lastModifiedDatetime instanceof Date && typeof(deletedByUserId) == "number" && deletedDatetime instanceof Date && typeof(isDeleted) == "boolean") { - this.Id = id; - this.UserLevel = userLevel; - this.Username = username; - this.PasswordHash = passwordHash; - this.PasswordSalt = passwordSalt; - this.APIKey = apiKey; - this.CreatedByUserId = createdByUserId; - this.CreatedDatetime = createdDateTime; - this.LastModifiedByUserId = lastModifiedByUserId; - this.LastModifiedDatetime = lastModifiedDatetime; - this.DeletedByUserId = deletedByUserId; - this.DeletedDatetime = deletedDatetime; - this.IsDeleted = isDeleted; - } else { - this.Id = Number.MIN_VALUE; - this.UserLevel = UserLevel.Unknown; - this.Username = ""; - this.PasswordHash = ""; - this.PasswordSalt = ""; - this.APIKey = ""; - this.CreatedByUserId = Number.MIN_VALUE; - this.CreatedDatetime = new Date(0); - this.IsDeleted = false; - } + public constructor() { + this.Id = Number.MIN_VALUE; + this.UserLevel = UserLevel.Unknown; + this.Username = ""; + this.PasswordHash = ""; + this.PasswordSalt = ""; + this.APIKey = ""; + this.HasUsedClient = false; + this.CreatedByUserId = Number.MIN_VALUE; + this.CreatedDatetime = new Date(0); + this.IsDeleted = false; } } \ No newline at end of file diff --git a/server/entities/UserBadge.ts b/server/entities/UserBadge.ts new file mode 100644 index 0000000..d7418c7 --- /dev/null +++ b/server/entities/UserBadge.ts @@ -0,0 +1,21 @@ +export default class UserBadge { + public Id: number; + public UserId: number; + public BadgeId: number; + public CreatedByUserId: number; + public CreatedDatetime: Date; + public LastModifiedByUserId?: number; + public LastModifiedDatetime?: Date; + public DeletedByUserId?: number; + public DeletedDatetime?: Date; + public IsDeleted: boolean; + + public constructor() { + this.Id = Number.MIN_VALUE; + this.UserId = Number.MIN_VALUE; + this.BadgeId = Number.MIN_VALUE; + this.CreatedByUserId = Number.MIN_VALUE; + this.CreatedDatetime = new Date(0); + this.IsDeleted = false; + } +} \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 9115258..f8a0018 100644 --- a/server/index.ts +++ b/server/index.ts @@ -50,6 +50,8 @@ import PartyService from "./services/PartyService"; import AdminController_Auth$Admin from "./controller/AdminController"; import { join } from "path"; import WsData from "./objects/WsData"; +import BadgeCache from "./objects/BadgeCache"; +import Badge from "./entities/Badge"; Console.customHeader(`MultiProbe server started at ${new Date()}`); @@ -58,6 +60,8 @@ WsData.users = users; new Database(Config.database.address, Config.database.port, Config.database.username, Config.database.password, Config.database.name); +BadgeCache.RefreshCache(); + // Web stuff const fastify = Fastify({ @@ -237,9 +241,10 @@ websocketServer.on("connection", (socket) => { } let page = rawURL.toLowerCase().replace(".htm", "").replace(".html", ""); - if (page === "index") { - page = ""; + if (page.endsWith("/index")) { + page = page.replace("/index", "/"); } + let lengthOfUsernames = 0; const usersOnPage = new Array(); await users.forEach(otherUser => { @@ -261,6 +266,40 @@ websocketServer.on("connection", (socket) => { user.send(usersToSend.toBuffer()); sendGroupUpdate(user); updateConnectionMetrics(); + + const badgeForPage = BadgeCache.UrlBadges.get(page); + if (badgeForPage && await UserService.UnlockBadgeIfNotUnlocked(dbUser.Id, badgeForPage.Id)) { + user.sendBadge(badgeForPage.Name, badgeForPage.Description, badgeForPage.ImageUrl); + } + + const goalBadgeKeys = BadgeCache.GoalBadges.keys; + let badge:Badge; + for (const key of goalBadgeKeys) { + switch (key) { + case "use_first_time": + { + if (!dbUser.HasUsedClient) { + await UserService.SaveUserClientLoginFlag(dbUser.Id); + dbUser.HasUsedClient = true; + badge = BadgeCache.GoalBadges.get(key)!; + if (await UserService.UnlockBadgeIfNotUnlocked(dbUser.Id, badge.Id)) { + user.sendBadge(badge.Name, badge.Description, badge.ImageUrl); + } + } + break; + } + case "first_time_party": + { + if (dbUserParty) { + badge = BadgeCache.GoalBadges.get(key)!; + if (await UserService.UnlockBadgeIfNotUnlocked(dbUser.Id, badge.Id)) { + user.sendBadge(badge.Name, badge.Description, badge.ImageUrl); + } + } + break; + } + } + } break; } case MessageType.CursorPos: diff --git a/server/objects/BadgeCache.ts b/server/objects/BadgeCache.ts new file mode 100644 index 0000000..ecdf916 --- /dev/null +++ b/server/objects/BadgeCache.ts @@ -0,0 +1,30 @@ +import Badge from "../entities/Badge"; +import FunkyArray from "funky-array"; +import BadgeService from "../services/BadgeService"; + +export default abstract class BadgeCache { + public static UrlBadges:FunkyArray; + public static GoalBadges:FunkyArray; + + public static async RefreshCache() { + const badges = await BadgeService.LoadAll(); + const urlBadges = new FunkyArray(); + const goalBadges = new FunkyArray(); + for (const badge of badges) { + const urlParts = badge.ForUrl.split("://"); + let url = urlParts[1].replace("www.", "").toLowerCase().replace(".htm", "").replace(".html", ""); + if (url.endsWith("/index")) { + url = url.replace("/index", "/"); + } + + if (urlParts[0] === "mp") { + goalBadges.set(url, badge); + } else { + urlBadges.set(url, badge); + } + } + + BadgeCache.UrlBadges = urlBadges; + BadgeCache.GoalBadges = goalBadges; + } +} \ No newline at end of file diff --git a/server/objects/RemoteUser.ts b/server/objects/RemoteUser.ts index f3c5a1f..87bb492 100644 --- a/server/objects/RemoteUser.ts +++ b/server/objects/RemoteUser.ts @@ -1,5 +1,7 @@ +import { createWriter, Endian } from "bufferstuff"; import IMetric from "simple-prom/lib/interfaces/IMetric"; import { WebSocket } from "ws"; +import { MessageType } from "../enums/MessageType"; export default class RemoteUser { private static USER_IDS = 0; @@ -47,4 +49,9 @@ export default class RemoteUser { this.messagesOut.add(1); this.socket.send(data); } + + sendBadge(name:string, description:string, imageUrl:string) { + const formattedDescription = description.replaceAll("\r", "").replaceAll("\n", "
"); + this.send(createWriter(Endian.LE, 7 + name.length * 2 + formattedDescription.length * 2 + imageUrl.length * 2).writeByte(MessageType.BadgeUnlock).writeString16(name).writeString16(formattedDescription).writeString16(imageUrl).toBuffer()); + } } \ No newline at end of file diff --git a/server/repos/UserBadgeRepo.ts b/server/repos/UserBadgeRepo.ts new file mode 100644 index 0000000..32ffa59 --- /dev/null +++ b/server/repos/UserBadgeRepo.ts @@ -0,0 +1,54 @@ +import UserBadge from "../entities/UserBadge"; +import Database from "../objects/Database"; +import RepoBase from "./RepoBase"; + +export default abstract class UserBadgeRepo { + public static async selectById(id:number) { + const dbUserBadge = await Database.Instance.query("SELECT * FROM UserBadge WHERE Id = ? AND IsDeleted = 0 LIMIT 1", [id]); + if (dbUserBadge == null || dbUserBadge.length === 0) { + return null; + } else { + const userBadge = new UserBadge(); + populateUserBadgeFromDB(userBadge, dbUserBadge[0]); + return userBadge; + } + } + + public static async selectByUserIdBadgeId(userId:number, badgeId:number) { + const dbUserBadge = await Database.Instance.query("SELECT * FROM UserBadge WHERE UserId = ? AND BadgeId = ? AND IsDeleted = 0 LIMIT 1", [ userId, badgeId ]); + if (dbUserBadge == null || dbUserBadge.length === 0) { + return null; + } else { + const userBadge = new UserBadge(); + populateUserBadgeFromDB(userBadge, dbUserBadge[0]); + return userBadge; + } + } + + public static async insertUpdate(userBadge:UserBadge) { + if (userBadge.Id === Number.MIN_VALUE) { + userBadge.Id = (await Database.Instance.query("INSERT UserBadge (UserId, BadgeId, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [ + userBadge.UserId, userBadge.BadgeId, userBadge.CreatedByUserId, userBadge.CreatedDatetime.getTime(), userBadge.LastModifiedByUserId ?? null, userBadge.LastModifiedDatetime?.getTime() ?? null, userBadge.DeletedByUserId ?? null, userBadge.DeletedDatetime?.getTime() ?? null, Number(userBadge.IsDeleted) + ]))[0]["Id"]; + } else { + await Database.Instance.query(`UPDATE UserBadge SET UserId = ?, BadgeId = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [ + userBadge.UserId, userBadge.BadgeId, userBadge.CreatedByUserId, userBadge.CreatedDatetime.getTime(), userBadge.LastModifiedByUserId ?? null, userBadge.LastModifiedDatetime?.getTime() ?? null, userBadge.DeletedByUserId ?? null, userBadge.DeletedDatetime?.getTime() ?? null, Number(userBadge.IsDeleted), userBadge.Id + ]); + } + + return userBadge; + } +} + +function populateUserBadgeFromDB(userBadge:UserBadge, dbUser:any) { + userBadge.Id = dbUser.Id; + userBadge.UserId = dbUser.UserId; + userBadge.BadgeId = dbUser.BadgeId; + userBadge.CreatedByUserId = dbUser.CreatedByUserId; + userBadge.CreatedDatetime = new Date(dbUser.CreatedDatetime); + userBadge.LastModifiedByUserId = dbUser.LastModifiedByUserId; + userBadge.LastModifiedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUser.LastModifiedDatetime); + userBadge.DeletedByUserId = dbUser.DeletedByUserId; + userBadge.DeletedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUser.DeletedDatetime); + userBadge.IsDeleted = dbUser.IsDeleted === 1; +} \ No newline at end of file diff --git a/server/repos/UserRepo.ts b/server/repos/UserRepo.ts index 3009ac0..d06481a 100644 --- a/server/repos/UserRepo.ts +++ b/server/repos/UserRepo.ts @@ -57,12 +57,12 @@ export default class UserRepo { public static async insertUpdate(user:User) { if (user.Id === Number.MIN_VALUE) { - user.Id = (await Database.Instance.query("INSERT User (UserLevelId, Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [ + user.Id = (await Database.Instance.query("INSERT User (UserLevelId, Username, PasswordHash, PasswordSalt, HasUsedClient, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [ user.UserLevel, user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted) ]))[0]["Id"]; } else { - await Database.Instance.query(`UPDATE User SET UserLevelId = ?, Username = ?, PasswordHash = ?, PasswordSalt = ?, APIKey = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [ - user.UserLevel, user.Username, user.PasswordHash, user.PasswordSalt, user.APIKey, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id + await Database.Instance.query(`UPDATE User SET UserLevelId = ?, Username = ?, PasswordHash = ?, PasswordSalt = ?, APIKey = ?, HasUsedClient = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [ + user.UserLevel, user.Username, user.PasswordHash, user.PasswordSalt, user.APIKey, Number(user.HasUsedClient), user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id ]); } @@ -77,6 +77,7 @@ function populateUserFromDB(user:User, dbUser:any) { user.PasswordHash = dbUser.PasswordHash; user.PasswordSalt = dbUser.PasswordSalt; user.APIKey = dbUser.APIKey; + user.HasUsedClient = dbUser.HasUsedClient === 1; user.CreatedByUserId = dbUser.CreatedByUserId; user.CreatedDatetime = new Date(dbUser.CreatedDatetime); user.LastModifiedByUserId = dbUser.LastModifiedByUserId; diff --git a/server/services/BadgeService.ts b/server/services/BadgeService.ts index 7541d02..16454fd 100644 --- a/server/services/BadgeService.ts +++ b/server/services/BadgeService.ts @@ -1,6 +1,7 @@ import { Console } from "hsconsole"; import Badge from "../entities/Badge"; import BadgeRepo from "../repos/BadgeRepo"; +import BadgeCache from "../objects/BadgeCache"; export default abstract class BadgeService { public static async SaveBadge(currentUserId:number, id:number | undefined, name:string, description:string, imageUrl: string, forUrl:string) { @@ -20,7 +21,11 @@ export default abstract class BadgeService { badge.ImageUrl = imageUrl; badge.ForUrl = forUrl; - return await BadgeRepo.insertUpdate(badge); + badge = await BadgeRepo.insertUpdate(badge); + + await BadgeCache.RefreshCache(); + + return badge; } catch (e) { Console.printError(`MultiProbe server service error:\n${e}`); throw e; diff --git a/server/services/UserService.ts b/server/services/UserService.ts index bd96be7..9131da4 100644 --- a/server/services/UserService.ts +++ b/server/services/UserService.ts @@ -5,6 +5,8 @@ import PasswordUtility from "../utilities/PasswordUtility"; import UserParty from "../entities/UserParty"; import UserPartyRepo from "../repos/UserPartyRepo"; import { UserLevel } from "../enums/UserLevel"; +import UserBadgeRepo from "../repos/UserBadgeRepo"; +import UserBadge from "../entities/UserBadge"; export default class UserService { public static async AuthenticateUser(username:string, password:string) { @@ -227,4 +229,44 @@ export default class UserService { throw e; } } + + public static async UnlockBadgeIfNotUnlocked(currentUserId:number, badgeId:number) { + try { + let userBadge = await UserBadgeRepo.selectByUserIdBadgeId(currentUserId, badgeId); + if (userBadge) { + return false; + } + + userBadge = new UserBadge(); + userBadge.UserId = currentUserId; + userBadge.BadgeId = badgeId; + userBadge.CreatedByUserId = currentUserId; + userBadge.CreatedDatetime = new Date(); + + await UserBadgeRepo.insertUpdate(userBadge) + + return true; + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } + + public static async SaveUserClientLoginFlag(currentUserId:number) { + try { + const user = await UserRepo.selectById(currentUserId); + if (!user) { + return null; + } + + user.HasUsedClient = true; + user.LastModifiedByUserId = currentUserId; + user.LastModifiedDatetime = new Date(); + + return await UserRepo.insertUpdate(user); + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } } \ No newline at end of file