From 23dbbf26e3068e987301833524297f22f0aa1bbe Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 7 Feb 2025 16:54:36 +0000 Subject: [PATCH 01/34] handle x-forwarded-for in IP logs --- admin/accounthttp.go | 6 +++--- admin/http.go | 12 ++++++------ controller/config.go | 1 + controller/ip.go | 10 ++++++---- model/appstate.go | 1 + 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/admin/accounthttp.go b/admin/accounthttp.go index fc03d77..125abdc 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -115,7 +115,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(r)) + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, "Password updated successfully.") @@ -145,7 +145,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { // check password if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(r)) + app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Incorrect password.") http.Redirect(w, r, "/admin/account", http.StatusFound) return @@ -159,7 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(r)) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionAccount(app.DB, session, nil) controller.SetSessionError(app.DB, session, "") diff --git a/admin/http.go b/admin/http.go index c70dd1d..b16c209 100644 --- a/admin/http.go +++ b/admin/http.go @@ -201,7 +201,7 @@ func registerAccountHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(r)) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r)) err = controller.DeleteInvite(app.DB, invite.Code) if err != nil { @@ -277,7 +277,7 @@ func loginHandler(app *model.AppState) http.Handler { err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Invalid username or password.") render() return @@ -305,7 +305,7 @@ func loginHandler(app *model.AppState) http.Handler { // login success! // TODO: log login activity to user - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(r)) + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) err = controller.SetSessionAccount(app.DB, session, account) @@ -363,7 +363,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { totpCode := r.FormValue("totp") if len(totpCode) != controller.TOTP_CODE_LENGTH { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return @@ -377,13 +377,13 @@ func loginTOTPHandler(app *model.AppState) http.Handler { return } if totpMethod == nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(r)) + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) if err != nil { diff --git a/controller/config.go b/controller/config.go index 8772b6b..5a69d49 100644 --- a/controller/config.go +++ b/controller/config.go @@ -21,6 +21,7 @@ func GetConfig() model.Config { BaseUrl: "https://arimelody.me", Host: "0.0.0.0", Port: 8080, + TrustedProxies: []string{ "127.0.0.1" }, DB: model.DBConfig{ Host: "127.0.0.1", Port: 5432, diff --git a/controller/ip.go b/controller/ip.go index 4b1126d..ae9d587 100644 --- a/controller/ip.go +++ b/controller/ip.go @@ -1,19 +1,21 @@ package controller import ( + "arimelody-web/model" "net/http" "slices" + "strings" ) // Returns the request's original IP address, resolving the `x-forwarded-for` // header if the request originates from a trusted proxy. -func ResolveIP(r *http.Request) string { - trustedProxies := []string{ "10.4.20.69" } - if slices.Contains(trustedProxies, r.RemoteAddr) { +func ResolveIP(app *model.AppState, r *http.Request) string { + addr := strings.Split(r.RemoteAddr, ":")[0] + if slices.Contains(app.Config.TrustedProxies, addr) { forwardedFor := r.Header.Get("x-forwarded-for") if len(forwardedFor) > 0 { return forwardedFor } } - return r.RemoteAddr + return addr } diff --git a/model/appstate.go b/model/appstate.go index 2516b6e..233e0db 100644 --- a/model/appstate.go +++ b/model/appstate.go @@ -26,6 +26,7 @@ type ( Host string `toml:"host"` Port int64 `toml:"port"` DataDirectory string `toml:"data_dir"` + TrustedProxies []string `toml:"trusted_proxies"` DB DBConfig `toml:"db"` Discord DiscordConfig `toml:"discord"` } From edb4d7df3a9544de8dcbafe1f162ed882114e9d3 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 7 Feb 2025 17:15:02 +0000 Subject: [PATCH 02/34] trim extra IPs from x-forwarded-for header --- bundle.sh | 2 +- controller/ip.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bundle.sh b/bundle.sh index dc7c023..277bb9c 100755 --- a/bundle.sh +++ b/bundle.sh @@ -6,4 +6,4 @@ if [ ! -f arimelody-web ]; then exit 1 fi -tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ +tar czf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ diff --git a/controller/ip.go b/controller/ip.go index ae9d587..233d76a 100644 --- a/controller/ip.go +++ b/controller/ip.go @@ -14,6 +14,8 @@ func ResolveIP(app *model.AppState, r *http.Request) string { if slices.Contains(app.Config.TrustedProxies, addr) { forwardedFor := r.Header.Get("x-forwarded-for") if len(forwardedFor) > 0 { + // discard extra IPs; cloudflare tends to append their nodes + forwardedFor = strings.Split(forwardedFor, ", ")[0] return forwardedFor } } From 3cb8d2940aadcc78072736975337d6766512611e Mon Sep 17 00:00:00 2001 From: ari melody Date: Sat, 8 Feb 2025 12:25:19 +0000 Subject: [PATCH 03/34] fix white-space on log message --- admin/static/logs.css | 1 + 1 file changed, 1 insertion(+) diff --git a/admin/static/logs.css b/admin/static/logs.css index 6ed91b5..f0df299 100644 --- a/admin/static/logs.css +++ b/admin/static/logs.css @@ -71,6 +71,7 @@ th.log-type { td.log-content, td.log-content { width: 100%; + white-space: collapse; } .log:hover { From 0fc6c9f86d6b1b74ead9179d4cd0fd4961b68711 Mon Sep 17 00:00:00 2001 From: ari melody Date: Wed, 19 Feb 2025 01:59:12 +0000 Subject: [PATCH 04/34] update public gpg key --- public/keys/ari melody_0x92678188_public.asc | 31 ++++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/public/keys/ari melody_0x92678188_public.asc b/public/keys/ari melody_0x92678188_public.asc index 2b37d88..80a4676 100644 --- a/public/keys/ari melody_0x92678188_public.asc +++ b/public/keys/ari melody_0x92678188_public.asc @@ -1,13 +1,26 @@ -----BEGIN PGP PUBLIC KEY BLOCK----- mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO -JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJMEExYKADsWIQTu -jeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbAwULCQgHAgIiAgYVCgkICwIEFgID -AQIeBwIXgAAKCRDPmYKckmeBiGCbAP4wTcLCU5ZlfSTJrFtGhQKWA6DxtUO7Cegk -Vu8SgkY3KgEA1/YqjZ1vSaqPDN4137vmhkhfduoYOjN0iptNj39u2wG4OARk1bTd -EgorBgEEAZdVAQUBAQdAnA2drPzQBoXNdwIrFnovuF0CjX+8+8QSugCF4a5ZEXED -AQgHiHgEGBYKACAWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbDAAKCRDP -mYKckmeBiC/xAQD1hu4WcstR40lkUxMqhZ44wmizrDA+eGCdh7Ge3Gy79wEAx385 -GnYoNplMTA4BTGs7orV4WSfSkoBx0+px1UOewgs= -=M1Bp +JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF +CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB +iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ ++oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI +kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC +BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF +ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P +f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL +d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq +VgUJBaOeTACBdiAEGRYKAB0WIQQlu5dWmBR/P3ZxngxgtfA4bj3bfgUCZ7UmigAK +CRBgtfA4bj3bfux+AP4y5ydrjnGBMX7GuB2nh55SRdscSiXsZ66ntnjXyQcbWgEA +pDuu7FqXzXcnluuZxNFDT740Rnzs60tTeplDqGGWcAQJEM+ZgpySZ4GIc0kA/iSw +Nw+r3FC75omwrPpJF13B5fq93FweFx+oSaES6qzkAQDvgCK77qKKbvCju0g8zSsK +EZnv6xR4uvtGdVkvLpBdC7gzBGe1JpkWCSsGAQQB2kcPAQEHQGnU4lXFLchhKYkC +PshP+jvuRsNoedaDOK2p4dkQC8JuiH4EGBYKACYCGyAWIQTujeuNYocuegkeKt/P +mYKckmeBiAUCZ7UqXgUJBaOeRQAKCRDPmYKckmeBiL9KAQCJZIBhuSsoYa61I0XZ +cKzGZbB0h9pD6eg1VRswNIgHtQEAwu9Hgs1rs9cySvKbO7WgK6Qh6EfrvGgGOXCO +m3wVsg24OARntSo5EgorBgEEAZdVAQUBAQdA+/k586W1OHxndzDJNpbd+wqjyjr0 +D5IXxfDs00advB0DAQgHiH4EGBYKACYWIQTujeuNYocuegkeKt/PmYKckmeBiAUC +Z7UqOQIbDAUJBaOagAAKCRDPmYKckmeBiEFxAQCgziQt2l3u7jnZVij4zop+K2Lv +TVFtkbG61tf6brRzBgD/X6c6X5BRyQC51JV1I1RFRBdeMAIXzcLFg2v3WUMccQs= +=YmHI -----END PGP PUBLIC KEY BLOCK----- From 739390d7f5b8c1c7fa820a0753afc7bb611ca48c Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 14 Mar 2025 00:33:59 +0000 Subject: [PATCH 05/34] funny cursor teehee --- public/script/cursor.js | 273 ++++++++++++++++++++++++++++++++++++++++ public/script/main.js | 1 + public/style/cursor.css | 43 +++++++ public/style/main.css | 1 + 4 files changed, 318 insertions(+) create mode 100644 public/script/cursor.js create mode 100644 public/style/cursor.css diff --git a/public/script/cursor.js b/public/script/cursor.js new file mode 100644 index 0000000..94f1a8e --- /dev/null +++ b/public/script/cursor.js @@ -0,0 +1,273 @@ +const CURSOR_TICK_RATE = 1000/30; +const CURSOR_LERP_RATE = 1/100; +const CURSOR_CHAR_MAX_LIFE = 5000; +const CURSOR_MAX_CHARS = 50; +/** @type HTMLElement */ +let cursorContainer; +/** @type Cursor */ +let myCursor; +/** @type Map */ +let cursors = new Map(); +/** @type Array */ +let chars = new Array(); + +let lastCursorUpdateTime = 0; +let lastCharUpdateTime = 0; + +class Cursor { + /** @type number */ id; + + // real coordinates (canonical) + /** @type number */ x; + /** @type number */ y; + + // update coordinates (interpolated) + /** @type number */ rx; + /** @type number */ ry; + + /** @type HTMLElement */ + #element; + /** @type HTMLElement */ + #char; + + /** + * @param {number} id + * @param {number} x + * @param {number} y + */ + constructor(id, x, y) { + this.id = id; + this.x = x; + this.y = y; + this.rx = x; + this.ry = y; + + const element = document.createElement("i"); + element.classList.add("cursor"); + element.id = "cursor" + id; + const colour = randomColour(); + element.style.borderColor = colour; + element.style.color = colour; + element.innerText = "0x" + navigator.userAgent.hashCode(); + + const char = document.createElement("p"); + char.className = "char"; + element.appendChild(char); + + this.#element = element; + this.#char = char; + cursorContainer.appendChild(this.#element); + } + + destroy() { + this.#element.remove(); + } + + /** + * @param {number} x + * @param {number} y + */ + move(x, y) { + this.x = x; + this.y = y; + } + + /** + * @param {number} deltaTime + */ + update(deltaTime) { + this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime; + this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime; + this.#element.style.left = this.rx + "px"; + this.#element.style.top = this.ry + "px"; + } + + /** + * @param {string} text + */ + print(text) { + if (text.length > 1) return; + this.#char.innerText = text; + } +} + +class FunChar { + /** @type number */ x; y; + /** @type number */ xa; ya; + /** @type number */ r; ra; + /** @type HTMLElement */ element; + /** @type number */ life; + + /** + * @param {number} x + * @param {number} y + * @param {number} xa + * @param {number} ya + * @param {string} text + */ + constructor(x, y, xa, ya, text) { + this.x = x; + this.y = y; + this.xa = xa + Math.random() * .2 - .1; + this.ya = ya + Math.random() * -.25; + this.r = this.xa * 100; + this.ra = this.r * 0.01; + this.life = 0; + + const char = document.createElement("i"); + char.className = "funchar"; + char.innerText = text; + char.style.left = x + "px"; + char.style.top = y + "px"; + char.style.transform = `rotate(${this.r}deg)`; + this.element = char; + cursorContainer.appendChild(this.element); + } + + /** + * @param {number} deltaTime + */ + update(deltaTime) { + this.life += deltaTime; + if (this.life > CURSOR_CHAR_MAX_LIFE || + this.y > window.outerHeight || + this.x < 0 || + this.x > window.outerWidth + ) { + this.destroy(); + return; + } + + this.x += this.xa * deltaTime; + this.y += this.ya * deltaTime; + this.r += this.ra * deltaTime; + this.ya = Math.min(this.ya + 0.0005 * deltaTime, 10); + + this.element.style.left = this.x + "px"; + this.element.style.top = this.y + "px"; + this.element.style.transform = `rotate(${this.r}deg)`; + } + + destroy() { + chars = chars.filter(char => char !== this); + this.element.remove(); + } +} + +String.prototype.hashCode = function() { + var hash = 0; + if (this.length === 0) return hash; + for (let i = 0; i < this.length; i++) { + const chr = this.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // convert to 32-bit integer + } + return Math.round(Math.abs(hash)).toString(16).slice(0, 8).padStart(8, '0'); +}; + +/** + * @returns string + */ +function randomColour() { + const min = 128; + const range = 100; + const red = Math.round((min + Math.random() * range)).toString(16); + const green = Math.round((min + Math.random() * range)).toString(16); + const blue = Math.round((min + Math.random() * range)).toString(16); + + return "#" + red + green + blue; +} + +/** + * @param {MouseEvent} event + */ +function handleMouseMove(event) { + if (!myCursor) return; + myCursor.move(event.x, event.y); +} + +/** + * @param {KeyboardEvent} event + */ +function handleKeyDown(event) { + if (event.key.length > 1) return; + if (event.metaKey || event.ctrlKey) return; + if (window.cursorFunMode === true) { + const yOffset = -20; + const accelMultiplier = 0.002; + if (chars.length < CURSOR_MAX_CHARS) + chars.push(new FunChar( + myCursor.x, myCursor.y + yOffset, + (myCursor.x - myCursor.rx) * accelMultiplier, (myCursor.y - myCursor.ry) * accelMultiplier, + event.key)); + } else { + myCursor.print(event.key); + } +} + +function handleKeyUp() { + if (!window.cursorFunMode) { + myCursor.print(""); + } +} + +/** + * @param {number} time + */ +function updateCursors(time) { + const deltaTime = time - lastCursorUpdateTime; + + cursors.forEach(cursor => { + cursor.update(deltaTime); + }); + + lastCursorUpdateTime = time; + requestAnimationFrame(updateCursors); +} + +/** + * @param {number} time + */ +function updateChars(time) { + const deltaTime = time - lastCharUpdateTime; + + chars.forEach(char => { + char.update(deltaTime); + }); + + lastCharUpdateTime = time; + requestAnimationFrame(updateChars); +} + +function cursorSetup() { + window.cursorFunMode = false; + cursorContainer = document.createElement("div"); + cursorContainer.id = "cursors"; + document.body.appendChild(cursorContainer); + myCursor = new Cursor(0, window.innerWidth / 2, window.innerHeight / 2); + cursors.set(0, myCursor); + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + requestAnimationFrame(updateCursors); + requestAnimationFrame(updateChars); + console.debug(`Cursor tracking @ ${window.location.pathname}`); +} + +function cursorDestroy() { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + cursors.forEach(cursor => { + cursor.destroy(); + }); + cursors.clear(); + chars.forEach(cursor => { + cursor.destroy(); + }); + chars = new Array(); + myCursor = null; + console.debug(`Cursor no longer tracking.`); +} + +cursorSetup(); diff --git a/public/script/main.js b/public/script/main.js index 95023e5..c1d5101 100644 --- a/public/script/main.js +++ b/public/script/main.js @@ -1,5 +1,6 @@ import "./header.js"; import "./config.js"; +import "./cursor.js"; function type_out(e) { const text = e.innerText; diff --git a/public/style/cursor.css b/public/style/cursor.css new file mode 100644 index 0000000..477e525 --- /dev/null +++ b/public/style/cursor.css @@ -0,0 +1,43 @@ +#cursors i.cursor { + position: fixed; + padding: 4px; + + display: block; + z-index: 1000; + + background: #0008; + border: 2px solid #808080; + border-radius: 2px; + + font-style: normal; + font-size: 10px; + font-weight: bold; + white-space: nowrap; + + user-select: none; + pointer-events: none; +} + +#cursors i.cursor .char { + position: absolute; + transform: translateY(-44px); + margin: 0; + font-size: 20px; + color: var(--on-background); +} + +#cursors .funchar { + position: fixed; + margin: 0; + + display: block; + z-index: 1000; + + font-style: normal; + font-size: 20px; + font-weight: bold; + color: var(--on-background); + + user-select: none; + pointer-events: none; +} diff --git a/public/style/main.css b/public/style/main.css index f7f2131..4e9e113 100644 --- a/public/style/main.css +++ b/public/style/main.css @@ -2,6 +2,7 @@ @import url("/style/header.css"); @import url("/style/footer.css"); @import url("/style/prideflag.css"); +@import url("/style/cursor.css"); @font-face { font-family: "Monaspace Argon"; From a797e82a6814fb285cd6ad5ca71cfdee9807032d Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 14 Mar 2025 16:26:30 +0000 Subject: [PATCH 06/34] waow i really like doing config overhauls don't i + cursor improvements --- public/script/config.js | 122 ++++++++++++++++++++++++++++++++-------- public/script/cursor.js | 122 ++++++++++++++++++++++++++++------------ public/style/cursor.css | 26 ++++++++- 3 files changed, 208 insertions(+), 62 deletions(-) diff --git a/public/script/config.js b/public/script/config.js index 2bb33a6..1ab8b5a 100644 --- a/public/script/config.js +++ b/public/script/config.js @@ -1,33 +1,107 @@ -const DEFAULT_CONFIG = { - crt: false -}; -const config = (() => { - let saved = localStorage.getItem("config"); - if (saved) { - const config = JSON.parse(saved); - setCRT(config.crt || DEFAULT_CONFIG.crt); - return config; +const ARIMELODY_CONFIG_NAME = "arimelody.me-config"; + +class Config { + _crt = false; + _cursor = false; + _cursorFunMode = false; + + /** @type Map> */ + #listeners = new Map(); + + constructor(values) { + function thisOrElse(values, name, defaultValue) { + if (values === null) return defaultValue; + if (values[name] === undefined) return defaultValue; + return values[name]; + } + + this.#listeners.set('crt', new Array()); + this.crt = thisOrElse(values, 'crt', false); + this.#listeners.set('cursor', new Array()); + this.cursor = thisOrElse(values, 'cursor', false); + this.#listeners.set('cursorFunMode', new Array()); + this.cursorFunMode = thisOrElse(values, 'cursorFunMode', false); + this.save(); } - localStorage.setItem("config", JSON.stringify(DEFAULT_CONFIG)); - return DEFAULT_CONFIG; -})(); + /** + * Appends a listener function to be called when the config value of `name` + * is changed. + */ + addListener(name, callback) { + const callbacks = this.#listeners.get(name); + if (!callbacks) return; + callbacks.push(callback); + } -function saveConfig() { - localStorage.setItem("config", JSON.stringify(config)); + /** + * Removes the listener function `callback` from the list of callbacks when + * the config value of `name` is changed. + */ + removeListener(name, callback) { + const callbacks = this.#listeners.get(name); + if (!callbacks) return; + callbacks.set(name, callbacks.filter(c => c !== callback)); + } + + save() { + localStorage.setItem(ARIMELODY_CONFIG_NAME, JSON.stringify({ + crt: this.crt, + cursor: this.cursor, + cursorFunMode: this.cursorFunMode + })); + } + + get crt() { return this._crt } + set crt(/** @type boolean */ enabled) { + this._crt = enabled; + + if (enabled) { + document.body.classList.add("crt"); + } else { + document.body.classList.remove("crt"); + } + document.getElementById('toggle-crt').className = enabled ? "" : "disabled"; + + this.#listeners.get('crt').forEach(callback => { + callback(this._crt); + }) + + this.save(); + } + + get cursor() { return this._cursor } + set cursor(/** @type boolean */ value) { + this._cursor = value; + this.#listeners.get('cursor').forEach(callback => { + callback(this._cursor); + }) + this.save(); + } + + get cursorFunMode() { return this._cursorFunMode } + set cursorFunMode(/** @type boolean */ value) { + this._cursorFunMode = value; + this.#listeners.get('cursorFunMode').forEach(callback => { + callback(this._cursorFunMode); + }) + this.save(); + } } +const config = (() => { + let values = null; + + const saved = localStorage.getItem(ARIMELODY_CONFIG_NAME); + if (saved) + values = JSON.parse(saved); + + return new Config(values); +})(); + document.getElementById("toggle-crt").addEventListener("click", () => { config.crt = !config.crt; - setCRT(config.crt); - saveConfig(); }); -function setCRT(/** @type boolean */ enabled) { - if (enabled) { - document.body.classList.add("crt"); - } else { - document.body.classList.remove("crt"); - } - document.getElementById('toggle-crt').className = enabled ? "" : "disabled"; -} +window.config = config; +export default config; diff --git a/public/script/cursor.js b/public/script/cursor.js index 94f1a8e..a1df6bb 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -1,7 +1,10 @@ +import config from './config.js'; + const CURSOR_TICK_RATE = 1000/30; const CURSOR_LERP_RATE = 1/100; const CURSOR_CHAR_MAX_LIFE = 5000; const CURSOR_MAX_CHARS = 50; + /** @type HTMLElement */ let cursorContainer; /** @type Cursor */ @@ -11,6 +14,7 @@ let cursors = new Map(); /** @type Array */ let chars = new Array(); +let running = false; let lastCursorUpdateTime = 0; let lastCharUpdateTime = 0; @@ -42,16 +46,16 @@ class Cursor { this.rx = x; this.ry = y; - const element = document.createElement("i"); - element.classList.add("cursor"); - element.id = "cursor" + id; + const element = document.createElement('i'); + element.classList.add('cursor'); + element.id = 'cursor' + id; const colour = randomColour(); element.style.borderColor = colour; element.style.color = colour; - element.innerText = "0x" + navigator.userAgent.hashCode(); + element.innerText = '0x' + navigator.userAgent.hashCode(); - const char = document.createElement("p"); - char.className = "char"; + const char = document.createElement('p'); + char.className = 'char'; element.appendChild(char); this.#element = element; @@ -61,6 +65,7 @@ class Cursor { destroy() { this.#element.remove(); + this.#char.remove(); } /** @@ -78,8 +83,8 @@ class Cursor { update(deltaTime) { this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime; this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime; - this.#element.style.left = this.rx + "px"; - this.#element.style.top = this.ry + "px"; + this.#element.style.left = this.rx + 'px'; + this.#element.style.top = this.ry + 'px'; } /** @@ -89,6 +94,16 @@ class Cursor { if (text.length > 1) return; this.#char.innerText = text; } + + /** + * @param {boolean} active + */ + click(active) { + if (active) + this.#element.classList.add('click'); + else + this.#element.classList.remove('click'); + } } class FunChar { @@ -114,11 +129,11 @@ class FunChar { this.ra = this.r * 0.01; this.life = 0; - const char = document.createElement("i"); - char.className = "funchar"; + const char = document.createElement('i'); + char.className = 'funchar'; char.innerText = text; - char.style.left = x + "px"; - char.style.top = y + "px"; + char.style.left = x + 'px'; + char.style.top = y + 'px'; char.style.transform = `rotate(${this.r}deg)`; this.element = char; cursorContainer.appendChild(this.element); @@ -130,9 +145,9 @@ class FunChar { update(deltaTime) { this.life += deltaTime; if (this.life > CURSOR_CHAR_MAX_LIFE || - this.y > window.outerHeight || + this.y > document.body.clientHeight || this.x < 0 || - this.x > window.outerWidth + this.x > document.body.clientWidth ) { this.destroy(); return; @@ -143,8 +158,8 @@ class FunChar { this.r += this.ra * deltaTime; this.ya = Math.min(this.ya + 0.0005 * deltaTime, 10); - this.element.style.left = this.x + "px"; - this.element.style.top = this.y + "px"; + this.element.style.left = (this.x - window.scrollX) + 'px'; + this.element.style.top = (this.y - window.scrollY) + 'px'; this.element.style.transform = `rotate(${this.r}deg)`; } @@ -175,7 +190,7 @@ function randomColour() { const green = Math.round((min + Math.random() * range)).toString(16); const blue = Math.round((min + Math.random() * range)).toString(16); - return "#" + red + green + blue; + return '#' + red + green + blue; } /** @@ -186,18 +201,25 @@ function handleMouseMove(event) { myCursor.move(event.x, event.y); } +function handleMouseDown() { + myCursor.click(true); +} +function handleMouseUp() { + myCursor.click(false); +} + /** * @param {KeyboardEvent} event */ function handleKeyDown(event) { if (event.key.length > 1) return; if (event.metaKey || event.ctrlKey) return; - if (window.cursorFunMode === true) { + if (config.cursorFunMode === true) { const yOffset = -20; const accelMultiplier = 0.002; if (chars.length < CURSOR_MAX_CHARS) chars.push(new FunChar( - myCursor.x, myCursor.y + yOffset, + myCursor.x + window.scrollX, myCursor.y + window.scrollY + yOffset, (myCursor.x - myCursor.rx) * accelMultiplier, (myCursor.y - myCursor.ry) * accelMultiplier, event.key)); } else { @@ -206,8 +228,8 @@ function handleKeyDown(event) { } function handleKeyUp() { - if (!window.cursorFunMode) { - myCursor.print(""); + if (!config.cursorFunMode) { + myCursor.print(''); } } @@ -215,6 +237,8 @@ function handleKeyUp() { * @param {number} time */ function updateCursors(time) { + if (!running) return; + const deltaTime = time - lastCursorUpdateTime; cursors.forEach(cursor => { @@ -229,6 +253,8 @@ function updateCursors(time) { * @param {number} time */ function updateChars(time) { + if (!running) return; + const deltaTime = time - lastCharUpdateTime; chars.forEach(char => { @@ -240,34 +266,60 @@ function updateChars(time) { } function cursorSetup() { - window.cursorFunMode = false; - cursorContainer = document.createElement("div"); - cursorContainer.id = "cursors"; + if (running) throw new Error('Only one instance of Cursor can run at a time.'); + running = true; + + cursorContainer = document.createElement('div'); + cursorContainer.id = 'cursors'; document.body.appendChild(cursorContainer); + myCursor = new Cursor(0, window.innerWidth / 2, window.innerHeight / 2); cursors.set(0, myCursor); - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keyup", handleKeyUp); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + requestAnimationFrame(updateCursors); requestAnimationFrame(updateChars); + console.debug(`Cursor tracking @ ${window.location.pathname}`); } function cursorDestroy() { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keyup", handleKeyUp); + if (!running) return; + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + + chars.forEach(char => { + char.destroy(); + }); + chars = new Array(); cursors.forEach(cursor => { cursor.destroy(); }); cursors.clear(); - chars.forEach(cursor => { - cursor.destroy(); - }); - chars = new Array(); myCursor = null; + + cursorContainer.remove(); + console.debug(`Cursor no longer tracking.`); + running = false; } -cursorSetup(); +if (config.cursor === true) { + cursorSetup(); +} + +config.addListener('cursor', enabled => { + if (enabled === true) + cursorSetup(); + else + cursorDestroy(); +}); diff --git a/public/style/cursor.css b/public/style/cursor.css index 477e525..e53cee5 100644 --- a/public/style/cursor.css +++ b/public/style/cursor.css @@ -13,9 +13,11 @@ font-size: 10px; font-weight: bold; white-space: nowrap; +} - user-select: none; - pointer-events: none; +#cursors i.cursor.click { + color: var(--on-background) !important; + border-color: var(--on-background) !important; } #cursors i.cursor .char { @@ -27,7 +29,7 @@ } #cursors .funchar { - position: fixed; + position: absolute; margin: 0; display: block; @@ -37,7 +39,25 @@ font-size: 20px; font-weight: bold; color: var(--on-background); +} + +#cursors { + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + + z-index: 1000; + overflow: clip; user-select: none; pointer-events: none; } + +@media (prefers-color-scheme: light) { + #cursors i.cursor { + filter: saturate(5) brightness(0.8); + background: #fff8; + } +} From 4b3c8f94497c6a5d5e5e5e73e128326736cefd1d Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 14 Mar 2025 20:42:44 +0000 Subject: [PATCH 07/34] lol cursor is multiplayer now --- cursor/cursor.go | 189 ++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + log/log.go | 1 + main.go | 16 ++- public/script/cursor.js | 253 ++++++++++++++++++++++++---------------- public/style/cursor.css | 7 +- 7 files changed, 362 insertions(+), 107 deletions(-) create mode 100644 cursor/cursor.go diff --git a/cursor/cursor.go b/cursor/cursor.go new file mode 100644 index 0000000..a74c998 --- /dev/null +++ b/cursor/cursor.go @@ -0,0 +1,189 @@ +package cursor + +import ( + "arimelody-web/model" + "fmt" + "math/rand" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type CursorClient struct { + ID int32 + Conn *websocket.Conn + Route string + X int16 + Y int16 + Disconnected bool +} + +type CursorMessage struct { + Data []byte + Route string + Exclude []*CursorClient +} + +func (client *CursorClient) Send(data []byte) { + err := client.Conn.WriteMessage(websocket.TextMessage, data) + if err != nil { + client.Disconnect() + } +} + +func (client *CursorClient) Disconnect() { + client.Disconnected = true + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("leave:%d", client.ID)), + client.Route, + []*CursorClient{}, + } +} + +var clients = make(map[int32]*CursorClient) +var broadcast = make(chan CursorMessage) +var mutex = &sync.Mutex{} + +func StartCursor(app *model.AppState) { + var includes = func (clients []*CursorClient, client *CursorClient) bool { + for _, c := range clients { + if c.ID == client.ID { return true } + } + return false + } + + log("Cursor message handler ready!") + + for { + message := <-broadcast + mutex.Lock() + for _, client := range clients { + if client.Route != message.Route { continue } + if includes(message.Exclude, client) { continue } + client.Send(message.Data) + } + mutex.Unlock() + } +} + +func handleClient(client *CursorClient) { + msgType, message, err := client.Conn.ReadMessage() + if err != nil { + client.Disconnect() + return + } + if msgType != websocket.TextMessage { return } + + args := strings.Split(string(message), ":") + if len(args) == 0 { return } + switch args[0] { + case "loc": + if len(args) < 2 { return } + + client.Route = args[1] + + mutex.Lock() + for _, otherClient := range clients { + if otherClient.ID == client.ID { continue } + if otherClient.Route != client.Route { continue } + client.Send([]byte(fmt.Sprintf("join:%d", otherClient.ID))) + client.Send([]byte(fmt.Sprintf("pos:%d:%d:%d", otherClient.ID, otherClient.X, otherClient.Y))) + } + mutex.Unlock() + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("join:%d", client.ID)), + client.Route, + []*CursorClient{ client }, + } + case "char": + if len(args) < 2 { return } + // haha, turns out using ':' as a separator means you can't type ':'s + // i should really be writing byte packets, not this nonsense + msg := byte(':') + if len(args[1]) > 0 { + msg = args[1][0] + } + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("char:%d:%c", client.ID, msg)), + client.Route, + []*CursorClient{ client }, + } + case "nochar": + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("nochar:%d", client.ID)), + client.Route, + []*CursorClient{ client }, + } + case "pos": + if len(args) < 3 { return } + x, err := strconv.ParseInt(args[1], 10, 32) + y, err := strconv.ParseInt(args[2], 10, 32) + if err != nil { return } + client.X = int16(x) + client.Y = int16(y) + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("pos:%d:%d:%d", client.ID, client.X, client.Y)), + client.Route, + []*CursorClient{ client }, + } + } +} + +func Handler(app *model.AppState) http.HandlerFunc { + var upgrader = websocket.Upgrader{ + CheckOrigin: func (r *http.Request) bool { + origin := r.Header.Get("Origin") + return origin == app.Config.BaseUrl + }, + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log("Failed to upgrade to WebSocket connection: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + defer conn.Close() + + client := CursorClient{ + ID: rand.Int31(), + Conn: conn, + X: 0.0, + Y: 0.0, + Disconnected: false, + } + + err = client.Conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("id:%d", client.ID))) + if err != nil { + client.Conn.Close() + return + } + + mutex.Lock() + clients[client.ID] = &client + mutex.Unlock() + + // log("Client connected: %s (%s)", fmt.Sprintf("0x%08x", client.ID), client.Conn.RemoteAddr().String()) + + for { + if client.Disconnected { + mutex.Lock() + delete(clients, client.ID) + client.Conn.Close() + mutex.Unlock() + return + } + handleClient(&client) + } + }) +} + +func log(format string, args ...any) { + logString := fmt.Sprintf(format, args...) + fmt.Printf("[%s] [CURSOR] %s\n", time.Now().Format(time.UnixDate), logString) +} diff --git a/go.mod b/go.mod index f8717a2..a1c6c76 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( require golang.org/x/crypto v0.27.0 // indirect require ( + github.com/gorilla/websocket v1.5.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect ) diff --git a/go.sum b/go.sum index 15259a1..f2ec7e7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= diff --git a/log/log.go b/log/log.go index 3d023ab..2d1c0c2 100644 --- a/log/log.go +++ b/log/log.go @@ -30,6 +30,7 @@ const ( TYPE_ARTWORK string = "artwork" TYPE_FILES string = "files" TYPE_MISC string = "misc" + TYPE_CURSOR string = "cursor" ) type LogLevel int diff --git a/main.go b/main.go index 03e9d77..2252622 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,13 @@ package main import ( + "bufio" "errors" "fmt" stdLog "log" "math" "math/rand" + "net" "net/http" "os" "path/filepath" @@ -17,9 +19,10 @@ import ( "arimelody-web/api" "arimelody-web/colour" "arimelody-web/controller" + "arimelody-web/cursor" + "arimelody-web/log" "arimelody-web/model" "arimelody-web/templates" - "arimelody-web/log" "arimelody-web/view" "github.com/jmoiron/sqlx" @@ -428,6 +431,8 @@ func main() { os.Exit(1) } + go cursor.StartCursor(&app) + // start the web server! mux := createServeMux(&app) fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) @@ -444,6 +449,7 @@ func createServeMux(app *model.AppState) *http.ServeMux { mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) + mux.Handle("/cursor-ws", cursor.Handler(app)) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -536,6 +542,14 @@ type LoggingResponseWriter struct { Status int } +func (lrw *LoggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijack, ok := lrw.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("Server does not support hijacking\n") + } + return hijack.Hijack() +} + func (lrw *LoggingResponseWriter) WriteHeader(status int) { lrw.Status = status lrw.ResponseWriter.WriteHeader(status) diff --git a/public/script/cursor.js b/public/script/cursor.js index a1df6bb..059a2bd 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -2,8 +2,9 @@ import config from './config.js'; const CURSOR_TICK_RATE = 1000/30; const CURSOR_LERP_RATE = 1/100; +const CURSOR_FUNCHAR_RATE = 20; const CURSOR_CHAR_MAX_LIFE = 5000; -const CURSOR_MAX_CHARS = 50; +const CURSOR_MAX_CHARS = 64; /** @type HTMLElement */ let cursorContainer; @@ -11,61 +12,57 @@ let cursorContainer; let myCursor; /** @type Map */ let cursors = new Map(); -/** @type Array */ -let chars = new Array(); + +/** @type WebSocket */ +let ws; let running = false; -let lastCursorUpdateTime = 0; -let lastCharUpdateTime = 0; +let lastUpdate = 0; class Cursor { - /** @type number */ id; - - // real coordinates (canonical) - /** @type number */ x; - /** @type number */ y; - - // update coordinates (interpolated) - /** @type number */ rx; - /** @type number */ ry; - /** @type HTMLElement */ #element; /** @type HTMLElement */ - #char; + #charElement; + #funCharCooldown = CURSOR_FUNCHAR_RATE; /** - * @param {number} id + * @param {string} id * @param {number} x * @param {number} y */ constructor(id, x, y) { - this.id = id; + // real coordinates (canonical) this.x = x; this.y = y; + // render coordinates (interpolated) this.rx = x; this.ry = y; + this.msg = ''; + this.funChars = new Array(); const element = document.createElement('i'); element.classList.add('cursor'); - element.id = 'cursor' + id; const colour = randomColour(); element.style.borderColor = colour; element.style.color = colour; - element.innerText = '0x' + navigator.userAgent.hashCode(); - - const char = document.createElement('p'); - char.className = 'char'; - element.appendChild(char); - + cursorContainer.appendChild(element); this.#element = element; - this.#char = char; - cursorContainer.appendChild(this.#element); + + const char = document.createElement('i'); + char.className = 'char'; + cursorContainer.appendChild(char); + this.#charElement = char; + + this.setID(id); } destroy() { this.#element.remove(); - this.#char.remove(); + this.#charElement.remove(); + this.funChars.forEach(char => { + char.destroy(); + }); } /** @@ -85,14 +82,52 @@ class Cursor { this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime; this.#element.style.left = this.rx + 'px'; this.#element.style.top = this.ry + 'px'; + this.#charElement.style.left = this.rx + 'px'; + this.#charElement.style.top = (this.ry - 24) + 'px'; + + if (this.#funCharCooldown > 0) + this.#funCharCooldown -= deltaTime; + + if (this.msg.length > 0) { + if (config.cursorFunMode === true) { + if (this.#funCharCooldown <= 0) { + this.#funCharCooldown = CURSOR_FUNCHAR_RATE; + if (this.funChars.length >= CURSOR_MAX_CHARS) { + const char = this.funChars.shift(); + char.destroy(); + } + const yOffset = -20; + const accelMultiplier = 0.002; + this.funChars.push(new FunChar( + this.x + window.scrollX, this.y + window.scrollY + yOffset, + (this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier, + this.msg)); + } + } else { + this.#charElement.innerText = this.msg; + } + } else { + this.#charElement.innerText = ''; + } + + this.funChars.forEach(char => { + if (char.life > CURSOR_CHAR_MAX_LIFE || + char.y > document.body.clientHeight || + char.x < 0 || + char.x > document.body.clientWidth + ) { + this.funChars = this.funChars.filter(c => c !== this); + char.destroy(); + return; + } + char.update(deltaTime); + }); } - /** - * @param {string} text - */ - print(text) { - if (text.length > 1) return; - this.#char.innerText = text; + setID(id) { + this.id = id; + this.#element.id = 'cursor' + id; + this.#element.innerText = '0x' + id.toString(16).slice(0, 8).padStart(8, '0'); } /** @@ -107,12 +142,6 @@ class Cursor { } class FunChar { - /** @type number */ x; y; - /** @type number */ xa; ya; - /** @type number */ r; ra; - /** @type HTMLElement */ element; - /** @type number */ life; - /** * @param {number} x * @param {number} y @@ -144,14 +173,6 @@ class FunChar { */ update(deltaTime) { this.life += deltaTime; - if (this.life > CURSOR_CHAR_MAX_LIFE || - this.y > document.body.clientHeight || - this.x < 0 || - this.x > document.body.clientWidth - ) { - this.destroy(); - return; - } this.x += this.xa * deltaTime; this.y += this.ya * deltaTime; @@ -164,22 +185,10 @@ class FunChar { } destroy() { - chars = chars.filter(char => char !== this); this.element.remove(); } } -String.prototype.hashCode = function() { - var hash = 0; - if (this.length === 0) return hash; - for (let i = 0; i < this.length; i++) { - const chr = this.charCodeAt(i); - hash = ((hash << 5) - hash) + chr; - hash |= 0; // convert to 32-bit integer - } - return Math.round(Math.abs(hash)).toString(16).slice(0, 8).padStart(8, '0'); -}; - /** * @returns string */ @@ -198,6 +207,8 @@ function randomColour() { */ function handleMouseMove(event) { if (!myCursor) return; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send(`pos:${event.x}:${event.y}`); myCursor.move(event.x, event.y); } @@ -211,26 +222,19 @@ function handleMouseUp() { /** * @param {KeyboardEvent} event */ -function handleKeyDown(event) { +function handleKeyPress(event) { if (event.key.length > 1) return; if (event.metaKey || event.ctrlKey) return; - if (config.cursorFunMode === true) { - const yOffset = -20; - const accelMultiplier = 0.002; - if (chars.length < CURSOR_MAX_CHARS) - chars.push(new FunChar( - myCursor.x + window.scrollX, myCursor.y + window.scrollY + yOffset, - (myCursor.x - myCursor.rx) * accelMultiplier, (myCursor.y - myCursor.ry) * accelMultiplier, - event.key)); - } else { - myCursor.print(event.key); - } + if (myCursor.msg === event.key) return; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send(`char:${event.key}`); + myCursor.msg = event.key; } function handleKeyUp() { - if (!config.cursorFunMode) { - myCursor.print(''); - } + if (ws && ws.readyState == WebSocket.OPEN) + ws.send(`nochar`); + myCursor.msg = ''; } /** @@ -239,32 +243,16 @@ function handleKeyUp() { function updateCursors(time) { if (!running) return; - const deltaTime = time - lastCursorUpdateTime; + const deltaTime = time - lastUpdate; cursors.forEach(cursor => { cursor.update(deltaTime); }); - lastCursorUpdateTime = time; + lastUpdate = time; requestAnimationFrame(updateCursors); } -/** - * @param {number} time - */ -function updateChars(time) { - if (!running) return; - - const deltaTime = time - lastCharUpdateTime; - - chars.forEach(char => { - char.update(deltaTime); - }); - - lastCharUpdateTime = time; - requestAnimationFrame(updateChars); -} - function cursorSetup() { if (running) throw new Error('Only one instance of Cursor can run at a time.'); running = true; @@ -273,19 +261,82 @@ function cursorSetup() { cursorContainer.id = 'cursors'; document.body.appendChild(cursorContainer); - myCursor = new Cursor(0, window.innerWidth / 2, window.innerHeight / 2); + myCursor = new Cursor("You!", window.innerWidth / 2, window.innerHeight / 2); cursors.set(0, myCursor); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keypress', handleKeyPress); document.addEventListener('keyup', handleKeyUp); requestAnimationFrame(updateCursors); - requestAnimationFrame(updateChars); - console.debug(`Cursor tracking @ ${window.location.pathname}`); + ws = new WebSocket("/cursor-ws"); + + ws.addEventListener("open", () => { + console.log("Cursor connected to server successfully."); + + ws.send(`loc:${window.location.pathname}`); + }); + ws.addEventListener("error", error => { + console.error("Cursor WebSocket error:", error); + }); + ws.addEventListener("close", () => { + console.log("Cursor connection closed."); + }); + + ws.addEventListener("message", event => { + const args = String(event.data).split(":"); + if (args.length == 0) return; + + let id = 0; + /** @type Cursor */ + let cursor; + if (args.length > 1) { + id = Number(args[1]); + cursor = cursors.get(id); + } + + switch (args[0]) { + case 'id': { + myCursor.setID(Number(args[1])); + break; + } + case 'join': { + if (id === myCursor.id) break; + cursors.set(id, new Cursor(id, 0, 0)); + break; + } + case 'leave': { + if (!cursor || cursor === myCursor) break; + cursors.get(id).destroy(); + cursors.delete(id); + break; + } + case 'char': { + if (!cursor || cursor === myCursor) break; + cursor.msg = args[2]; + break; + } + case 'nochar': { + if (!cursor || cursor === myCursor) break; + cursor.msg = ''; + break; + } + case 'pos': { + if (!cursor || cursor === myCursor) break; + cursor.move(Number(args[2]), Number(args[3])); + break; + } + default: { + console.warn("Cursor: Unknown command received from server:", args[0]); + break; + } + } + }); + + console.log(`Cursor tracking @ ${window.location.pathname}`); } function cursorDestroy() { @@ -294,13 +345,9 @@ function cursorDestroy() { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keyup', handleKeyUp); - chars.forEach(char => { - char.destroy(); - }); - chars = new Array(); cursors.forEach(cursor => { cursor.destroy(); }); @@ -309,7 +356,7 @@ function cursorDestroy() { cursorContainer.remove(); - console.debug(`Cursor no longer tracking.`); + console.log(`Cursor no longer tracking.`); running = false; } diff --git a/public/style/cursor.css b/public/style/cursor.css index e53cee5..8561716 100644 --- a/public/style/cursor.css +++ b/public/style/cursor.css @@ -20,15 +20,16 @@ border-color: var(--on-background) !important; } -#cursors i.cursor .char { +#cursors i.char { position: absolute; - transform: translateY(-44px); margin: 0; + font-style: normal; font-size: 20px; + font-weight: bold; color: var(--on-background); } -#cursors .funchar { +#cursors i.funchar { position: absolute; margin: 0; From 7eac4765586e4cdc5f228777519c287e4f353e10 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 14 Mar 2025 20:47:58 +0000 Subject: [PATCH 08/34] random scripts are silly. MAKEFILES are where it's at --- .gitignore | 1 + Makefile | 12 ++++++++++++ bundle.sh | 9 --------- 3 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 Makefile delete mode 100755 bundle.sh diff --git a/.gitignore b/.gitignore index 9bdf788..2e63958 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docker-compose*.yml !docker-compose.example.yml config*.toml arimelody-web +arimelody-web.tar.gz diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f9784d5 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +EXEC = arimelody-web + +.PHONY: all + +all: + go build -o $(EXEC) + +bundle: $(EXEC) + tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ + +clean: + rm $(EXEC) $(EXEC).tar.gz diff --git a/bundle.sh b/bundle.sh deleted file mode 100755 index 277bb9c..0000000 --- a/bundle.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -# simple script to pack up arimelody.me for production distribution - -if [ ! -f arimelody-web ]; then - echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first." - exit 1 -fi - -tar czf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ From 3f3164bc153a70df2bd6352fb514a62646f7cc1c Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 14 Mar 2025 20:55:09 +0000 Subject: [PATCH 09/34] and the goddess spoke: don't make this silly mistake again --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index f9784d5..11e565a 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ EXEC = arimelody-web -.PHONY: all +.PHONY: $(EXEC) -all: - go build -o $(EXEC) +$(EXEC): + GOOS=linux GOARCH=amd64 go build -o $(EXEC) bundle: $(EXEC) tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ From ecda8dde24e23b089da9db09b156d64ee70ee95f Mon Sep 17 00:00:00 2001 From: ari melody Date: Sat, 15 Mar 2025 17:54:58 +0000 Subject: [PATCH 10/34] hell yeah i love canvas rendering --- cursor/cursor.go | 28 ++-- public/script/cursor.js | 297 ++++++++++++++++++++++------------------ public/style/cursor.css | 63 +-------- 3 files changed, 188 insertions(+), 200 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index a74c998..4e4504f 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -17,8 +17,9 @@ type CursorClient struct { ID int32 Conn *websocket.Conn Route string - X int16 - Y int16 + X float32 + Y float32 + Click bool Disconnected bool } @@ -91,7 +92,7 @@ func handleClient(client *CursorClient) { if otherClient.ID == client.ID { continue } if otherClient.Route != client.Route { continue } client.Send([]byte(fmt.Sprintf("join:%d", otherClient.ID))) - client.Send([]byte(fmt.Sprintf("pos:%d:%d:%d", otherClient.ID, otherClient.X, otherClient.Y))) + client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClient.ID, otherClient.X, otherClient.Y))) } mutex.Unlock() broadcast <- CursorMessage{ @@ -118,15 +119,26 @@ func handleClient(client *CursorClient) { client.Route, []*CursorClient{ client }, } + case "click": + if len(args) < 2 { return } + click := 0 + if args[1][0] == '1' { + click = 1 + } + broadcast <- CursorMessage{ + []byte(fmt.Sprintf("click:%d:%d", client.ID, click)), + client.Route, + []*CursorClient{ client }, + } case "pos": if len(args) < 3 { return } - x, err := strconv.ParseInt(args[1], 10, 32) - y, err := strconv.ParseInt(args[2], 10, 32) + x, err := strconv.ParseFloat(args[1], 32) + y, err := strconv.ParseFloat(args[2], 32) if err != nil { return } - client.X = int16(x) - client.Y = int16(y) + client.X = float32(x) + client.Y = float32(y) broadcast <- CursorMessage{ - []byte(fmt.Sprintf("pos:%d:%d:%d", client.ID, client.X, client.Y)), + []byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)), client.Route, []*CursorClient{ client }, } diff --git a/public/script/cursor.js b/public/script/cursor.js index 059a2bd..188cdbb 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -1,13 +1,14 @@ import config from './config.js'; -const CURSOR_TICK_RATE = 1000/30; const CURSOR_LERP_RATE = 1/100; const CURSOR_FUNCHAR_RATE = 20; const CURSOR_CHAR_MAX_LIFE = 5000; const CURSOR_MAX_CHARS = 64; -/** @type HTMLElement */ -let cursorContainer; +/** @type HTMLCanvasElement */ +let canvas; +/** @type CanvasRenderingContext2D */ +let ctx; /** @type Cursor */ let myCursor; /** @type Map */ @@ -19,11 +20,12 @@ let ws; let running = false; let lastUpdate = 0; +let cursorBoxHeight = 0; +let cursorBoxRadius = 0; +let cursorIDFontSize = 0; +let cursorCharFontSize = 0; + class Cursor { - /** @type HTMLElement */ - #element; - /** @type HTMLElement */ - #charElement; #funCharCooldown = CURSOR_FUNCHAR_RATE; /** @@ -32,46 +34,20 @@ class Cursor { * @param {number} y */ constructor(id, x, y) { + this.id = id; + // real coordinates (canonical) this.x = x; this.y = y; // render coordinates (interpolated) this.rx = x; this.ry = y; + this.msg = ''; + /** @type Array */ this.funChars = new Array(); - - const element = document.createElement('i'); - element.classList.add('cursor'); - const colour = randomColour(); - element.style.borderColor = colour; - element.style.color = colour; - cursorContainer.appendChild(element); - this.#element = element; - - const char = document.createElement('i'); - char.className = 'char'; - cursorContainer.appendChild(char); - this.#charElement = char; - - this.setID(id); - } - - destroy() { - this.#element.remove(); - this.#charElement.remove(); - this.funChars.forEach(char => { - char.destroy(); - }); - } - - /** - * @param {number} x - * @param {number} y - */ - move(x, y) { - this.x = x; - this.y = y; + this.colour = randomColour(); + this.click = false; } /** @@ -80,64 +56,80 @@ class Cursor { update(deltaTime) { this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime; this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime; - this.#element.style.left = this.rx + 'px'; - this.#element.style.top = this.ry + 'px'; - this.#charElement.style.left = this.rx + 'px'; - this.#charElement.style.top = (this.ry - 24) + 'px'; if (this.#funCharCooldown > 0) this.#funCharCooldown -= deltaTime; - if (this.msg.length > 0) { - if (config.cursorFunMode === true) { + const x = this.rx * innerWidth - scrollX; + const y = this.ry * innerHeight - scrollY; + const onBackground = ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background'); + + if (config.cursorFunMode === true) { + if (this.msg.length > 0) { if (this.#funCharCooldown <= 0) { this.#funCharCooldown = CURSOR_FUNCHAR_RATE; if (this.funChars.length >= CURSOR_MAX_CHARS) { - const char = this.funChars.shift(); - char.destroy(); + this.funChars.shift(); } - const yOffset = -20; + const yOffset = -10 / innerHeight; const accelMultiplier = 0.002; this.funChars.push(new FunChar( - this.x + window.scrollX, this.y + window.scrollY + yOffset, + this.x, this.y + yOffset, (this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier, this.msg)); } - } else { - this.#charElement.innerText = this.msg; } - } else { - this.#charElement.innerText = ''; + + this.funChars.forEach(char => { + if (char.life > CURSOR_CHAR_MAX_LIFE || + char.y - scrollY > innerHeight || + char.x < 0 || + char.x * innerWidth - scrollX > innerWidth + ) { + this.funChars = this.funChars.filter(c => c !== this); + return; + } + char.update(deltaTime); + }); + } else if (this.msg.length > 0) { + ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace'; + ctx.fillStyle = onBackground; + ctx.fillText( + this.msg, + (x + 6) * devicePixelRatio, + (y + -8) * devicePixelRatio); } - this.funChars.forEach(char => { - if (char.life > CURSOR_CHAR_MAX_LIFE || - char.y > document.body.clientHeight || - char.x < 0 || - char.x > document.body.clientWidth - ) { - this.funChars = this.funChars.filter(c => c !== this); - char.destroy(); - return; - } - char.update(deltaTime); - }); - } + const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches; - setID(id) { - this.id = id; - this.#element.id = 'cursor' + id; - this.#element.innerText = '0x' + id.toString(16).slice(0, 8).padStart(8, '0'); - } + if (lightTheme) + ctx.filter = 'saturate(5) brightness(0.8)'; - /** - * @param {boolean} active - */ - click(active) { - if (active) - this.#element.classList.add('click'); - else - this.#element.classList.remove('click'); + const idText = '0x' + this.id.toString(16).padStart(8, '0'); + const colour = this.click ? onBackground : this.colour; + + ctx.beginPath(); + ctx.roundRect( + (x) * devicePixelRatio, + (y) * devicePixelRatio, + (12 + 7.2 * idText.length) * devicePixelRatio, + cursorBoxHeight, + cursorBoxRadius); + ctx.closePath(); + ctx.fillStyle = lightTheme ? '#fff8' : '#0008'; + ctx.fill(); + ctx.strokeStyle = colour; + ctx.lineWidth = devicePixelRatio; + ctx.stroke(); + + ctx.font = cursorIDFontSize + 'px monospace'; + ctx.fillStyle = colour; + ctx.fillText( + idText, + (x + 6) * devicePixelRatio, + (y + 14) * devicePixelRatio); + + ctx.filter = ''; } } @@ -152,20 +144,12 @@ class FunChar { constructor(x, y, xa, ya, text) { this.x = x; this.y = y; - this.xa = xa + Math.random() * .2 - .1; - this.ya = ya + Math.random() * -.25; - this.r = this.xa * 100; + this.xa = xa + Math.random() * .0005 - .00025; + this.ya = ya + Math.random() * -.00025; + this.r = this.xa * 1000; this.ra = this.r * 0.01; + this.text = text; this.life = 0; - - const char = document.createElement('i'); - char.className = 'funchar'; - char.innerText = text; - char.style.left = x + 'px'; - char.style.top = y + 'px'; - char.style.transform = `rotate(${this.r}deg)`; - this.element = char; - cursorContainer.appendChild(this.element); } /** @@ -177,15 +161,27 @@ class FunChar { this.x += this.xa * deltaTime; this.y += this.ya * deltaTime; this.r += this.ra * deltaTime; - this.ya = Math.min(this.ya + 0.0005 * deltaTime, 10); + this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10); - this.element.style.left = (this.x - window.scrollX) + 'px'; - this.element.style.top = (this.y - window.scrollY) + 'px'; - this.element.style.transform = `rotate(${this.r}deg)`; - } + const x = this.x * innerWidth - scrollX; + const y = this.y * innerHeight - scrollY; - destroy() { - this.element.remove(); + const translateOffset = { + x: (x + 7.2) * devicePixelRatio, + y: (y - 7.2) * devicePixelRatio, + }; + ctx.translate(translateOffset.x, translateOffset.y); + ctx.rotate(this.r); + ctx.translate(-translateOffset.x, -translateOffset.y); + + ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace'; + ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background'); + ctx.fillText( + this.text, + x * devicePixelRatio, + y * devicePixelRatio); + + ctx.resetTransform(); } } @@ -205,18 +201,37 @@ function randomColour() { /** * @param {MouseEvent} event */ +let mouseMoveLock = false; +const mouseMoveCooldown = 1000/30; function handleMouseMove(event) { if (!myCursor) return; - if (ws && ws.readyState == WebSocket.OPEN) - ws.send(`pos:${event.x}:${event.y}`); - myCursor.move(event.x, event.y); + + const x = event.pageX / innerWidth; + const y = event.pageY / innerHeight; + const f = 10000; // four digit floating-point precision + + if (!mouseMoveLock) { + mouseMoveLock = true; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send(`pos:${Math.round(x * f) / f}:${Math.round(y * f) / f}`); + setTimeout(() => { + mouseMoveLock = false; + }, mouseMoveCooldown); + } + + myCursor.x = x; + myCursor.y = y; } function handleMouseDown() { - myCursor.click(true); + myCursor.click = true; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send('click:1'); } function handleMouseUp() { - myCursor.click(false); + myCursor.click = false; + if (ws && ws.readyState == WebSocket.OPEN) + ws.send('click:0'); } /** @@ -238,56 +253,69 @@ function handleKeyUp() { } /** - * @param {number} time + * @param {number} timestamp */ -function updateCursors(time) { +function update(timestamp) { if (!running) return; - const deltaTime = time - lastUpdate; + const deltaTime = timestamp - lastUpdate; + lastUpdate = timestamp; + + ctx.clearRect(0, 0, canvas.width, canvas.height); cursors.forEach(cursor => { cursor.update(deltaTime); }); - lastUpdate = time; - requestAnimationFrame(updateCursors); + requestAnimationFrame(update); +} + +function handleWindowResize() { + canvas.width = innerWidth * devicePixelRatio; + canvas.height = innerHeight * devicePixelRatio; + cursorBoxHeight = 20 * devicePixelRatio; + cursorBoxRadius = 4 * devicePixelRatio; + cursorIDFontSize = 12 * devicePixelRatio; + cursorCharFontSize = 20 * devicePixelRatio; } function cursorSetup() { if (running) throw new Error('Only one instance of Cursor can run at a time.'); running = true; - cursorContainer = document.createElement('div'); - cursorContainer.id = 'cursors'; - document.body.appendChild(cursorContainer); + canvas = document.createElement('canvas'); + canvas.id = 'cursors'; + handleWindowResize(); + document.body.appendChild(canvas); - myCursor = new Cursor("You!", window.innerWidth / 2, window.innerHeight / 2); + ctx = canvas.getContext('2d'); + + myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2); cursors.set(0, myCursor); + addEventListener('resize', handleWindowResize); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); document.addEventListener('keypress', handleKeyPress); document.addEventListener('keyup', handleKeyUp); - requestAnimationFrame(updateCursors); + requestAnimationFrame(update); - ws = new WebSocket("/cursor-ws"); + ws = new WebSocket('/cursor-ws'); + ws.addEventListener('open', () => { + console.log('Cursor connected to server successfully.'); - ws.addEventListener("open", () => { - console.log("Cursor connected to server successfully."); - - ws.send(`loc:${window.location.pathname}`); + ws.send(`loc:${location.pathname}`); }); - ws.addEventListener("error", error => { - console.error("Cursor WebSocket error:", error); + ws.addEventListener('error', error => { + console.error('Cursor WebSocket error:', error); }); - ws.addEventListener("close", () => { - console.log("Cursor connection closed."); + ws.addEventListener('close', () => { + console.log('Cursor connection closed.'); }); - - ws.addEventListener("message", event => { - const args = String(event.data).split(":"); + ws.addEventListener('message', event => { + const args = String(event.data).split(':'); if (args.length == 0) return; let id = 0; @@ -300,7 +328,7 @@ function cursorSetup() { switch (args[0]) { case 'id': { - myCursor.setID(Number(args[1])); + myCursor.id = Number(args[1]); break; } case 'join': { @@ -310,7 +338,6 @@ function cursorSetup() { } case 'leave': { if (!cursor || cursor === myCursor) break; - cursors.get(id).destroy(); cursors.delete(id); break; } @@ -324,33 +351,37 @@ function cursorSetup() { cursor.msg = ''; break; } + case 'click': { + if (!cursor || cursor === myCursor) break; + cursor.click = args[2] == '1'; + break; + } case 'pos': { if (!cursor || cursor === myCursor) break; - cursor.move(Number(args[2]), Number(args[3])); + cursor.x = Number(args[2]); + cursor.y = Number(args[3]); break; } default: { - console.warn("Cursor: Unknown command received from server:", args[0]); + console.warn('Cursor: Unknown command received from server:', args[0]); break; } } }); - console.log(`Cursor tracking @ ${window.location.pathname}`); + console.log(`Cursor tracking @ ${location.pathname}`); } function cursorDestroy() { if (!running) return; + removeEventListener('resize', handleWindowResize); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keyup', handleKeyUp); - cursors.forEach(cursor => { - cursor.destroy(); - }); cursors.clear(); myCursor = null; diff --git a/public/style/cursor.css b/public/style/cursor.css index 8561716..73019ee 100644 --- a/public/style/cursor.css +++ b/public/style/cursor.css @@ -1,64 +1,9 @@ -#cursors i.cursor { - position: fixed; - padding: 4px; - - display: block; - z-index: 1000; - - background: #0008; - border: 2px solid #808080; - border-radius: 2px; - - font-style: normal; - font-size: 10px; - font-weight: bold; - white-space: nowrap; -} - -#cursors i.cursor.click { - color: var(--on-background) !important; - border-color: var(--on-background) !important; -} - -#cursors i.char { - position: absolute; - margin: 0; - font-style: normal; - font-size: 20px; - font-weight: bold; - color: var(--on-background); -} - -#cursors i.funchar { - position: absolute; - margin: 0; - - display: block; - z-index: 1000; - - font-style: normal; - font-size: 20px; - font-weight: bold; - color: var(--on-background); -} - -#cursors { - width: 100vw; - height: 100vh; +canvas#cursors { position: fixed; top: 0; left: 0; - - z-index: 1000; - overflow: clip; - - user-select: none; + width: 100vw; + height: 100vh; pointer-events: none; -} - -@media (prefers-color-scheme: light) { - #cursors i.cursor { - filter: saturate(5) brightness(0.8); - background: #fff8; - } + z-index: 100; } From 51283c1a4fb8040c5911a3fcbcc1522735c61e0a Mon Sep 17 00:00:00 2001 From: ari melody Date: Sat, 15 Mar 2025 22:37:23 +0000 Subject: [PATCH 11/34] oops --- schema-migration/000-init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema-migration/000-init.sql b/schema-migration/000-init.sql index f70dee6..b56c6b6 100644 --- a/schema-migration/000-init.sql +++ b/schema-migration/000-init.sql @@ -84,7 +84,7 @@ CREATE TABLE arimelody.musicrelease ( buylink text, copyright text, copyrightURL text, - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp ); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); From e5ae16755086830bdd01d8784586f6f25ce4806d Mon Sep 17 00:00:00 2001 From: ari melody Date: Sat, 15 Mar 2025 22:47:37 +0000 Subject: [PATCH 12/34] incredibly important wii update --- views/index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/views/index.html b/views/index.html index 636c369..ba0ca15 100644 --- a/views/index.html +++ b/views/index.html @@ -197,7 +197,9 @@ sprunk go straight to hell virus alert! click here - wii + + wii + www get mandatory internet explorer HTML - learn it today! From 6db35b2f995376680395e94a1056141f3db521b7 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 24 Mar 2025 19:39:10 +0000 Subject: [PATCH 13/34] model function unit tests! --- .../components/release/release-list-item.html | 2 +- model/artist.go | 4 - model/artist_test.go | 23 +++ model/link.go | 4 +- model/link_test.go | 23 +++ model/release.go | 4 - model/release_test.go | 157 ++++++++++++++++++ model/track_test.go | 43 +++++ views/music-gateway.html | 10 +- 9 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 model/artist_test.go create mode 100644 model/link_test.go create mode 100644 model/release_test.go create mode 100644 model/track_test.go diff --git a/admin/components/release/release-list-item.html b/admin/components/release/release-list-item.html index 677318d..4b8f41e 100644 --- a/admin/components/release/release-list-item.html +++ b/admin/components/release/release-list-item.html @@ -7,7 +7,7 @@

{{.Title}} - {{.GetReleaseYear}} + {{.ReleaseDate.Year}} {{if not .Visible}}(hidden){{end}}

diff --git a/model/artist.go b/model/artist.go index 733e537..63871c7 100644 --- a/model/artist.go +++ b/model/artist.go @@ -9,10 +9,6 @@ type ( } ) -func (artist Artist) GetWebsite() string { - return artist.Website -} - func (artist Artist) GetAvatar() string { if artist.Avatar == "" { return "/img/default-avatar.png" diff --git a/model/artist_test.go b/model/artist_test.go new file mode 100644 index 0000000..feb9a18 --- /dev/null +++ b/model/artist_test.go @@ -0,0 +1,23 @@ +package model + +import ( + "testing" +) + +func Test_Artist_GetAvatar(t *testing.T) { + want := "testavatar.png" + artist := Artist{ Avatar: want } + + got := artist.GetAvatar() + if want != got { + t.Errorf(`correct value not returned when avatar is populated (want "%s", got "%s")`, want, got) + } + + artist = Artist{} + + want = "/img/default-avatar.png" + got = artist.GetAvatar() + if want != got { + t.Errorf(`default value not returned when avatar is empty (want "%s", got "%s")`, want, got) + } +} diff --git a/model/link.go b/model/link.go index 8b48ced..1a5bb8f 100644 --- a/model/link.go +++ b/model/link.go @@ -11,6 +11,6 @@ type Link struct { } func (link Link) NormaliseName() string { - rgx := regexp.MustCompile(`[^a-z0-9]`) - return strings.ToLower(rgx.ReplaceAllString(link.Name, "")) + rgx := regexp.MustCompile(`[^a-z0-9\-]`) + return rgx.ReplaceAllString(strings.ToLower(link.Name), "") } diff --git a/model/link_test.go b/model/link_test.go new file mode 100644 index 0000000..b368094 --- /dev/null +++ b/model/link_test.go @@ -0,0 +1,23 @@ +package model + +import ( + "testing" +) + +func Test_Link_NormaliseName(t *testing.T) { + link := Link{ + Name: "!c@o#o$l%-^a&w*e(s)o_m=e+-[l{i]n}k-0123456789ABCDEF", + } + + want := "cool-awesome-link-0123456789abcdef" + got := link.NormaliseName() + if want != got { + t.Errorf(`name with invalid characters not properly formatted (want "%s", got "%s")`, want, got) + } + + link.Name = want + got = link.NormaliseName() + if want != got { + t.Errorf(`valid name mangled by formatter (want "%s", got "%s")`, want, got) + } +} diff --git a/model/release.go b/model/release.go index 42d6fba..afaacca 100644 --- a/model/release.go +++ b/model/release.go @@ -50,10 +50,6 @@ func (release Release) PrintReleaseDate() string { return release.ReleaseDate.Format("02 January 2006") } -func (release Release) GetReleaseYear() int { - return release.ReleaseDate.Year() -} - func (release Release) GetArtwork() string { if release.Artwork == "" { return "/img/default-cover-art.png" diff --git a/model/release_test.go b/model/release_test.go new file mode 100644 index 0000000..11a58a1 --- /dev/null +++ b/model/release_test.go @@ -0,0 +1,157 @@ +package model + +import ( + "testing" + "time" +) + +func Test_Release_DescriptionHTML(t *testing.T) { + release := Release{ + Description: "this is\na test\ndescription!", + } + + // descriptions are set by privileged users, + // so we'll allow HTML injection here + want := "this is
a test
description!" + got := release.GetDescriptionHTML() + if want != string(got) { + t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_ReleaseDate(t *testing.T) { + release := Release{ + ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), + } + + want := "2025-07-26T16:00" + got := release.TextReleaseDate() + if want != got { + t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got) + } + + want = "26 July 2025" + got = release.PrintReleaseDate() + if want != got { + t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_Artwork(t *testing.T) { + want := "testartwork.png" + release := Release{ Artwork: want } + + got := release.GetArtwork() + if want != got { + t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got) + } + + release = Release{} + + want = "/img/default-cover-art.png" + got = release.GetArtwork() + if want != got { + t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_IsSingle(t *testing.T) { + release := Release{ + Tracks: []*Track{}, + } + + if release.IsSingle() { + t.Errorf("IsSingle() == true when no tracks are present") + } + + release.Tracks = append(release.Tracks, &Track{}) + if !release.IsSingle() { + t.Errorf("IsSingle() == false when one track is present") + } + + release.Tracks = append(release.Tracks, &Track{}) + if release.IsSingle() { + t.Errorf("IsSingle() == true when >1 tracks are present") + } +} + +func Test_Release_IsReleased(t *testing.T) { + release := Release { + ReleaseDate: time.Now(), + } + + if !release.IsReleased() { + t.Errorf("IsRelease() == false when release date in the past") + } + + release.ReleaseDate = time.Now().Add(time.Hour) + if release.IsReleased() { + t.Errorf("IsRelease() == true when release date in the future") + } +} + +func Test_Release_PrintArtists(t *testing.T) { + artist1 := "ari melody" + artist2 := "aridoodle" + artist3 := "idk" + artist4 := "guest" + + release := Release { + Credits: []*Credit{ + { Artist: Artist{ Name: artist1 }, Primary: true }, + { Artist: Artist{ Name: artist2 }, Primary: true }, + { Artist: Artist{ Name: artist3 }, Primary: false }, + { Artist: Artist{ Name: artist4 }, Primary: true }, + }, + } + + { + want := []string{ artist1, artist2, artist4 } + got := release.GetUniqueArtistNames(true) + if len(want) != len(got) { + t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want)) + } + for i := range got { + if want[i] != got[i] { + t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i]) + } + } + + want = []string{ artist1, artist2, artist3, artist4 } + got = release.GetUniqueArtistNames(false) + if len(want) != len(got) { + t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want)) + } + for i := range got { + if want[i] != got[i] { + t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i]) + } + } + } + + { + want := "ari melody, aridoodle & guest" + got := release.PrintArtists(true, true) + if want != got { + t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, guest" + got = release.PrintArtists(true, false) + if want != got { + t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, idk & guest" + got = release.PrintArtists(false, true) + if want != got { + t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, idk, guest" + got = release.PrintArtists(false, false) + if want != got { + t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got) + } + } +} diff --git a/model/track_test.go b/model/track_test.go new file mode 100644 index 0000000..fd500d7 --- /dev/null +++ b/model/track_test.go @@ -0,0 +1,43 @@ +package model + +import ( + "testing" +) + +func Test_Track_DescriptionHTML(t *testing.T) { + track := Track{ + Description: "this is\na test\ndescription!", + } + + // descriptions are set by privileged users, + // so we'll allow HTML injection here + want := "this is
a test
description!" + got := track.GetDescriptionHTML() + if want != string(got) { + t.Errorf(`track description incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Track_LyricsHTML(t *testing.T) { + track := Track{ + Lyrics: "these are\ntest\nlyrics!", + } + + // lyrics are set by privileged users, + // so we'll allow HTML injection here + want := "these are
test
lyrics!" + got := track.GetLyricsHTML() + if want != string(got) { + t.Errorf(`track lyrics incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Track_Add(t *testing.T) { + track := Track{} + + want := 4 + got := track.Add(2, 2) + if want != got { + t.Errorf(`somehow, we screwed up addition. (want %d, got %d)`, want, got) + } +} diff --git a/views/music-gateway.html b/views/music-gateway.html index 1bf3a2f..0f4441c 100644 --- a/views/music-gateway.html +++ b/views/music-gateway.html @@ -4,7 +4,7 @@ - + @@ -54,7 +54,7 @@

{{.Title}}

- {{.GetReleaseYear}} + {{.ReleaseDate.Year}}

{{.PrintArtists true true}}

{{if .IsReleased}} @@ -91,7 +91,7 @@ {{end}} {{if and .Copyright .CopyrightURL}} - + {{end}} @@ -105,8 +105,8 @@
    {{range .Credits}} {{$Artist := .Artist}} - {{if $Artist.GetWebsite}} -
  • {{$Artist.Name}}: {{.Role}}
  • + {{if $Artist.Website}} +
  • {{$Artist.Name}}: {{.Role}}
  • {{else}}
  • {{$Artist.Name}}: {{.Role}}
  • {{end}} From ed86aff2a24657f5ee1d4f204c6ceaf97132d419 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 31 Mar 2025 13:36:02 +0100 Subject: [PATCH 14/34] fix config not saving with broken listeners, fix cursor ReferenceError --- cursor/cursor.go | 10 +++++----- public/script/config.js | 7 +++---- public/script/cursor.js | 4 +--- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index 4e4504f..4ed59e3 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -88,13 +88,13 @@ func handleClient(client *CursorClient) { client.Route = args[1] mutex.Lock() - for _, otherClient := range clients { - if otherClient.ID == client.ID { continue } - if otherClient.Route != client.Route { continue } - client.Send([]byte(fmt.Sprintf("join:%d", otherClient.ID))) - client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClient.ID, otherClient.X, otherClient.Y))) + for otherClientID, otherClient := range clients { + if otherClientID == client.ID || otherClient.Route != client.Route { continue } + client.Send([]byte(fmt.Sprintf("join:%d", otherClientID))) + client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClientID, otherClient.X, otherClient.Y))) } mutex.Unlock() + broadcast <- CursorMessage{ []byte(fmt.Sprintf("join:%d", client.ID)), client.Route, diff --git a/public/script/config.js b/public/script/config.js index 1ab8b5a..402a74b 100644 --- a/public/script/config.js +++ b/public/script/config.js @@ -55,6 +55,7 @@ class Config { get crt() { return this._crt } set crt(/** @type boolean */ enabled) { this._crt = enabled; + this.save(); if (enabled) { document.body.classList.add("crt"); @@ -66,26 +67,24 @@ class Config { this.#listeners.get('crt').forEach(callback => { callback(this._crt); }) - - this.save(); } get cursor() { return this._cursor } set cursor(/** @type boolean */ value) { this._cursor = value; + this.save(); this.#listeners.get('cursor').forEach(callback => { callback(this._cursor); }) - this.save(); } get cursorFunMode() { return this._cursorFunMode } set cursorFunMode(/** @type boolean */ value) { this._cursorFunMode = value; + this.save(); this.#listeners.get('cursorFunMode').forEach(callback => { callback(this._cursorFunMode); }) - this.save(); } } diff --git a/public/script/cursor.js b/public/script/cursor.js index 188cdbb..79637fe 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -328,7 +328,7 @@ function cursorSetup() { switch (args[0]) { case 'id': { - myCursor.id = Number(args[1]); + myCursor.id = id; break; } case 'join': { @@ -385,8 +385,6 @@ function cursorDestroy() { cursors.clear(); myCursor = null; - cursorContainer.remove(); - console.log(`Cursor no longer tracking.`); running = false; } From 5cc9a1ca7653351841c216d4b29e50941d72de53 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 31 Mar 2025 13:44:29 +0100 Subject: [PATCH 15/34] clear cursor display when shutting down --- public/script/cursor.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/script/cursor.js b/public/script/cursor.js index 79637fe..ff87068 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -381,6 +381,8 @@ function cursorDestroy() { document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keyup', handleKeyUp); + + ctx.clearRect(0, 0, canvas.width, canvas.height); cursors.clear(); myCursor = null; From 1c0e541c8914229f74d402690a05ee1a2c6168fd Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 29 Apr 2025 11:32:48 +0100 Subject: [PATCH 16/34] lock accounts after enough failed login attempts --- admin/http.go | 49 ++++++++++++++++++++-- controller/account.go | 23 +++++++++++ controller/migrator.go | 6 ++- main.go | 66 +++++++++++++++++++++++++++++- model/account.go | 17 ++++---- schema-migration/000-init.sql | 2 + schema-migration/003-fail-lock.sql | 3 ++ 7 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 schema-migration/003-fail-lock.sql diff --git a/admin/http.go b/admin/http.go index b16c209..4d09264 100644 --- a/admin/http.go +++ b/admin/http.go @@ -274,11 +274,20 @@ func loginHandler(app *model.AppState) http.Handler { render() return } + if account.Locked { + controller.SetSessionError(app.DB, session, "This account is locked.") + render() + return + } err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) - controller.SetSessionError(app.DB, session, "Invalid username or password.") + if locked := handleFailedLogin(app, account); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + } else { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + } render() return } @@ -299,6 +308,8 @@ func loginHandler(app *model.AppState) http.Handler { render() return } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin/totp", http.StatusFound) return } @@ -377,8 +388,14 @@ func loginTOTPHandler(app *model.AppState) http.Handler { return } if totpMethod == nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) - controller.SetSessionError(app.DB, session, "Invalid TOTP.") + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + if locked := handleFailedLogin(app, session.AttemptAccount); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + controller.SetSessionAttemptAccount(app.DB, session, nil) + http.Redirect(w, r, "/admin", http.StatusFound) + } else { + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") + } render() return } @@ -496,3 +513,29 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } + +func handleFailedLogin(app *model.AppState, account *model.Account) bool { + locked, err := controller.IncrementAccountFails(app.DB, account.ID) + if err != nil { + fmt.Fprintf( + os.Stderr, + "WARN: Failed to increment login failures for \"%s\": %v\n", + account.Username, + err, + ) + app.Log.Warn( + log.TYPE_ACCOUNT, + "Failed to increment login failures for \"%s\"", + account.Username, + ) + } + if locked { + app.Log.Warn( + log.TYPE_ACCOUNT, + "Account \"%s\" was locked: %d failed login attempts", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + ) + } + return locked +} diff --git a/controller/account.go b/controller/account.go index 9c7c1e1..e4f5dc4 100644 --- a/controller/account.go +++ b/controller/account.go @@ -110,3 +110,26 @@ func DeleteAccount(db *sqlx.DB, accountID string) error { _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) return err } + +func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) { + failAttempts := 0 + err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) + if err != nil { return false, err } + locked := false + if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS { + err = LockAccount(db, accountID) + if err != nil { return false, err } + locked = true + } + return locked, err +} + +func LockAccount(db *sqlx.DB, accountID string) error { + _, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) + return err +} + +func UnlockAccount(db *sqlx.DB, accountID string) error { + _, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) + return err +} diff --git a/controller/migrator.go b/controller/migrator.go index b053a27..b970a1b 100644 --- a/controller/migrator.go +++ b/controller/migrator.go @@ -8,7 +8,7 @@ import ( "github.com/jmoiron/sqlx" ) -const DB_VERSION int = 3 +const DB_VERSION int = 4 func CheckDBVersionAndMigrate(db *sqlx.DB) { db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody") @@ -45,6 +45,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) { ApplyMigration(db, "002-audit-logs") oldDBVersion = 3 + case 3: + ApplyMigration(db, "003-fail-lock") + oldDBVersion = 4 + } } diff --git a/main.go b/main.go index 2252622..360f7ac 100644 --- a/main.go +++ b/main.go @@ -276,11 +276,13 @@ func main() { "User: %s\n" + "\tID: %s\n" + "\tEmail: %s\n" + - "\tCreated: %s\n", + "\tCreated: %s\n" + + "\tLocked: %t\n", account.Username, account.ID, email, account.CreatedAt, + account.Locked, ) } return @@ -355,6 +357,64 @@ func main() { fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) return + case "lockAccount": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n") + os.Exit(1) + } + username := os.Args[2] + fmt.Printf("Unlocking account \"%s\"...\n", username) + + account, err := controller.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + err = controller.LockAccount(app.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err) + os.Exit(1) + } + + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username) + fmt.Printf("Account \"%s\" locked successfully.\n", account.Username) + return + + case "unlockAccount": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n") + os.Exit(1) + } + username := os.Args[2] + fmt.Printf("Unlocking account \"%s\"...\n", username) + + account, err := controller.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + err = controller.UnlockAccount(app.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err) + os.Exit(1) + } + + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username) + fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username) + return + case "logs": // TODO: add log search parameters logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0) @@ -389,7 +449,9 @@ func main() { "createInvite:\n\tCreates an invite code to register new accounts.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" + "listAccounts:\n\tLists all active accounts.\n", - "deleteAccount :\n\tDeletes an account with a given `username`.\n", + "deleteAccount :\n\tDeletes the account under `username`.\n", + "unlockAccount :\n\tUnlocks the account under `username`.\n", + "logs:\n\tShows system logs.\n", ) return } diff --git a/model/account.go b/model/account.go index 166e880..ad65b82 100644 --- a/model/account.go +++ b/model/account.go @@ -6,15 +6,18 @@ import ( ) const COOKIE_TOKEN string = "AM_SESSION" +const MAX_LOGIN_FAIL_ATTEMPTS int = 3 type ( - Account struct { - ID string `json:"id" db:"id"` - Username string `json:"username" db:"username"` - Password string `json:"password" db:"password"` - Email sql.NullString `json:"email" db:"email"` - AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` - CreatedAt time.Time `json:"created_at" db:"created_at"` + Account struct { + ID string `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Password string `json:"password" db:"password"` + Email sql.NullString `json:"email" db:"email"` + AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + FailAttempts int `json:"fail_attempts" db:"fail_attempts"` + Locked bool `json:"locked" db:"locked"` Privileges []AccountPrivilege `json:"privileges"` } diff --git a/schema-migration/000-init.sql b/schema-migration/000-init.sql index b56c6b6..2174109 100644 --- a/schema-migration/000-init.sql +++ b/schema-migration/000-init.sql @@ -19,6 +19,8 @@ CREATE TABLE arimelody.account ( email TEXT, avatar_url TEXT, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp + fail_attempts INT NOT NULL DEFAULT 0, + locked BOOLEAN DEFAULT false, ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); diff --git a/schema-migration/003-fail-lock.sql b/schema-migration/003-fail-lock.sql new file mode 100644 index 0000000..32d19bf --- /dev/null +++ b/schema-migration/003-fail-lock.sql @@ -0,0 +1,3 @@ +-- it would be nice to prevent brute-forcing +ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0; +ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false; From 562ed2e0150331aff4937a456ee6904bfc2c676f Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 29 Apr 2025 16:31:39 +0100 Subject: [PATCH 17/34] session validation/invalidation --- admin/http.go | 2 +- api/release.go | 2 +- controller/session.go | 19 ++++++++++++++++--- view/music.go | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/admin/http.go b/admin/http.go index 4d09264..bdacad3 100644 --- a/admin/http.go +++ b/admin/http.go @@ -483,7 +483,7 @@ func staticHandler() http.Handler { func enforceSession(app *model.AppState, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session, err := controller.GetSessionFromRequest(app.DB, r) + session, err := controller.GetSessionFromRequest(app, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/api/release.go b/api/release.go index efed8dd..5cb87b0 100644 --- a/api/release.go +++ b/api/release.go @@ -20,7 +20,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { // only allow authorised users to view hidden releases privileged := false if !release.Visible { - session, err := controller.GetSessionFromRequest(app.DB, r) + session, err := controller.GetSessionFromRequest(app, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/controller/session.go b/controller/session.go index cf423fe..dce7ad0 100644 --- a/controller/session.go +++ b/controller/session.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "arimelody-web/log" "arimelody-web/model" "github.com/jmoiron/sqlx" @@ -15,7 +16,7 @@ import ( const TOKEN_LEN = 64 -func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) { +func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) { sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) if err != nil && err != http.ErrNoCookie { return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err)) @@ -25,14 +26,26 @@ func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) if sessionCookie != nil { // fetch existing session - session, err = GetSession(db, sessionCookie.Value) + session, err = GetSession(app.DB, sessionCookie.Value) if err != nil && !strings.Contains(err.Error(), "no rows") { return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err)) } if session != nil { - // TODO: consider running security checks here (i.e. user agent mismatches) + if session.UserAgent != r.UserAgent() { + msg := "Session user agent mismatch. A cookie may have been hijacked!" + if session.Account != nil { + account, _ := GetAccountByID(app.DB, session.Account.ID) + msg += " (Account \"" + account.Username + "\")" + } + app.Log.Warn(log.TYPE_ACCOUNT, msg) + err = DeleteSession(app.DB, session.Token) + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session") + } + return nil, nil + } } } diff --git a/view/music.go b/view/music.go index 2d40ef0..dfe884e 100644 --- a/view/music.go +++ b/view/music.go @@ -60,7 +60,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler { // only allow authorised users to view hidden releases privileged := false if !release.Visible { - session, err := controller.GetSessionFromRequest(app.DB, r) + session, err := controller.GetSessionFromRequest(app, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) From fe4a7888989192548cab21f2137f8f7a372d7451 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 29 Apr 2025 23:22:48 +0100 Subject: [PATCH 18/34] update cli utility docs --- README.md | 3 +++ main.go | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e5df7f6..464379e 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,6 @@ need to be up for this, making this ideal for some offline maintenance. - `purgeInvites`: Deletes all available invite codes. - `listAccounts`: Lists all active accounts. - `deleteAccount `: Deletes an account with a given `username`. +- `lockAccount `: Locks the account under `username`. +- `unlockAccount `: Unlocks the account under `username`. +- `logs`: Shows system logs. diff --git a/main.go b/main.go index 360f7ac..c0f6ee2 100644 --- a/main.go +++ b/main.go @@ -450,8 +450,9 @@ func main() { "purgeInvites:\n\tDeletes all available invite codes.\n" + "listAccounts:\n\tLists all active accounts.\n", "deleteAccount :\n\tDeletes the account under `username`.\n", + "lockAccount :\n\tLocks the account under `username`.\n", "unlockAccount :\n\tUnlocks the account under `username`.\n", - "logs:\n\tShows system logs.\n", + "logs:\n\tShows system logs.\n", ) return } From 23a02617f91384953b0d24813a75d4a32e3a4e14 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 29 Apr 2025 23:25:32 +0100 Subject: [PATCH 19/34] fix indentation (tabs to 4 spaces) (oops) --- admin/accounthttp.go | 18 +++---- admin/artisthttp.go | 6 +-- admin/http.go | 108 ++++++++++++++++++++--------------------- admin/logshttp.go | 12 ++--- admin/releasehttp.go | 10 ++-- admin/templates.go | 12 ++--- admin/trackhttp.go | 6 +-- api/api.go | 16 +++--- api/artist.go | 78 ++++++++++++++--------------- api/release.go | 36 +++++++------- api/track.go | 36 +++++++------- api/uploads.go | 70 +++++++++++++------------- controller/account.go | 34 ++++++------- controller/artist.go | 56 ++++++++++----------- controller/config.go | 12 ++--- controller/invite.go | 10 ++-- controller/ip.go | 8 +-- controller/migrator.go | 8 +-- controller/qr.go | 14 +++--- controller/release.go | 8 +-- controller/session.go | 44 ++++++++--------- controller/totp.go | 26 +++++----- controller/track.go | 44 ++++++++--------- cursor/cursor.go | 18 +++---- discord/discord.go | 14 +++--- log/log.go | 8 +-- main.go | 52 ++++++++++---------- model/account.go | 22 ++++----- model/appstate.go | 2 +- model/artist.go | 20 ++++---- model/link.go | 12 ++--- model/release.go | 32 ++++++------ model/release_test.go | 4 +- model/session.go | 4 +- model/totp.go | 2 +- model/track.go | 16 +++--- templates/templates.go | 4 +- view/music.go | 12 ++--- 38 files changed, 447 insertions(+), 447 deletions(-) diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 125abdc..945a507 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,17 +1,17 @@ package admin import ( - "database/sql" - "fmt" - "net/http" - "net/url" - "os" + "database/sql" + "fmt" + "net/http" + "net/url" + "os" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) func accountHandler(app *model.AppState) http.Handler { diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 6dfbbfd..9fa6bb2 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -1,9 +1,9 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" "arimelody-web/model" "arimelody-web/controller" diff --git a/admin/http.go b/admin/http.go index bdacad3..76eb5f2 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,20 +1,20 @@ package admin import ( - "context" - "database/sql" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "context" + "database/sql" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) func Handler(app *model.AppState) http.Handler { @@ -274,20 +274,20 @@ func loginHandler(app *model.AppState) http.Handler { render() return } - if account.Locked { - controller.SetSessionError(app.DB, session, "This account is locked.") - render() - return - } + if account.Locked { + controller.SetSessionError(app.DB, session, "This account is locked.") + render() + return + } err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) - if locked := handleFailedLogin(app, account); locked { - controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") - } else { - controller.SetSessionError(app.DB, session, "Invalid username or password.") - } + if locked := handleFailedLogin(app, account); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + } else { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + } render() return } @@ -308,8 +308,8 @@ func loginHandler(app *model.AppState) http.Handler { render() return } - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin/totp", http.StatusFound) return } @@ -389,13 +389,13 @@ func loginTOTPHandler(app *model.AppState) http.Handler { } if totpMethod == nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) - if locked := handleFailedLogin(app, session.AttemptAccount); locked { - controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") - controller.SetSessionAttemptAccount(app.DB, session, nil) - http.Redirect(w, r, "/admin", http.StatusFound) - } else { - controller.SetSessionError(app.DB, session, "Incorrect TOTP.") - } + if locked := handleFailedLogin(app, session.AttemptAccount); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + controller.SetSessionAttemptAccount(app.DB, session, nil) + http.Redirect(w, r, "/admin", http.StatusFound) + } else { + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") + } render() return } @@ -515,27 +515,27 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler { } func handleFailedLogin(app *model.AppState, account *model.Account) bool { - locked, err := controller.IncrementAccountFails(app.DB, account.ID) - if err != nil { - fmt.Fprintf( - os.Stderr, - "WARN: Failed to increment login failures for \"%s\": %v\n", - account.Username, - err, - ) - app.Log.Warn( - log.TYPE_ACCOUNT, - "Failed to increment login failures for \"%s\"", - account.Username, - ) - } - if locked { - app.Log.Warn( - log.TYPE_ACCOUNT, - "Account \"%s\" was locked: %d failed login attempts", - account.Username, - model.MAX_LOGIN_FAIL_ATTEMPTS, - ) - } - return locked + locked, err := controller.IncrementAccountFails(app.DB, account.ID) + if err != nil { + fmt.Fprintf( + os.Stderr, + "WARN: Failed to increment login failures for \"%s\": %v\n", + account.Username, + err, + ) + app.Log.Warn( + log.TYPE_ACCOUNT, + "Failed to increment login failures for \"%s\"", + account.Username, + ) + } + if locked { + app.Log.Warn( + log.TYPE_ACCOUNT, + "Account \"%s\" was locked: %d failed login attempts", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + ) + } + return locked } diff --git a/admin/logshttp.go b/admin/logshttp.go index 93dc5b7..7249b16 100644 --- a/admin/logshttp.go +++ b/admin/logshttp.go @@ -1,12 +1,12 @@ package admin import ( - "arimelody-web/log" - "arimelody-web/model" - "fmt" - "net/http" - "os" - "strings" + "arimelody-web/log" + "arimelody-web/model" + "fmt" + "net/http" + "os" + "strings" ) func logsHandler(app *model.AppState) http.Handler { diff --git a/admin/releasehttp.go b/admin/releasehttp.go index 7ef4d37..c6b68ab 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -1,12 +1,12 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" ) func serveRelease(app *model.AppState) http.Handler { diff --git a/admin/templates.go b/admin/templates.go index 12cdf08..606d569 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,12 +1,12 @@ package admin import ( - "arimelody-web/log" - "fmt" - "html/template" - "path/filepath" - "strings" - "time" + "arimelody-web/log" + "fmt" + "html/template" + "path/filepath" + "strings" + "time" ) var indexTemplate = template.Must(template.ParseFiles( diff --git a/admin/trackhttp.go b/admin/trackhttp.go index a92f81a..93eacdb 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -1,9 +1,9 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" "arimelody-web/model" "arimelody-web/controller" diff --git a/api/api.go b/api/api.go index d3c83ce..398db4b 100644 --- a/api/api.go +++ b/api/api.go @@ -1,15 +1,15 @@ package api import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "strings" + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" ) func Handler(app *model.AppState) http.Handler { diff --git a/api/artist.go b/api/artist.go index 9006cc3..01899a6 100644 --- a/api/artist.go +++ b/api/artist.go @@ -1,66 +1,66 @@ package api import ( - "encoding/json" - "fmt" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" ) func ServeAllArtists(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} artists, err := controller.GetAllArtists(app.DB) - if err != nil { + if err != nil { fmt.Printf("WARN: Failed to serve all artists: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } - w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(w) encoder.SetIndent("", "\t") err = encoder.Encode(artists) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } }) } func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - type ( - creditJSON struct { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + type ( + creditJSON struct { ID string `json:"id"` Title string `json:"title"` ReleaseDate time.Time `json:"releaseDate" db:"release_date"` Artwork string `json:"artwork"` - Role string `json:"role"` - Primary bool `json:"primary"` - } - artistJSON struct { - *model.Artist - Credits map[string]creditJSON `json:"credits"` - } - ) + Role string `json:"role"` + Primary bool `json:"primary"` + } + artistJSON struct { + *model.Artist + Credits map[string]creditJSON `json:"credits"` + } + ) session := r.Context().Value("session").(*model.Session) show_hidden_releases := session != nil && session.Account != nil dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) - if err != nil { + if err != nil { fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } var credits = map[string]creditJSON{} for _, credit := range dbCredits { @@ -74,17 +74,17 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { } } - w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(w) encoder.SetIndent("", "\t") err = encoder.Encode(artistJSON{ Artist: artist, Credits: credits, }) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - }) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) } func CreateArtist(app *model.AppState) http.Handler { diff --git a/api/release.go b/api/release.go index 5cb87b0..e07f0d7 100644 --- a/api/release.go +++ b/api/release.go @@ -1,22 +1,22 @@ package api import ( - "encoding/json" - "fmt" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" ) func ServeRelease(app *model.AppState, release *model.Release) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases privileged := false if !release.Visible { @@ -116,15 +116,15 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { } } - w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(w) encoder.SetIndent("", "\t") err := encoder.Encode(response) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) } func ServeCatalog(app *model.AppState) http.Handler { diff --git a/api/track.go b/api/track.go index e7d7c07..4e48418 100644 --- a/api/track.go +++ b/api/track.go @@ -1,13 +1,13 @@ package api import ( - "encoding/json" - "fmt" - "net/http" + "encoding/json" + "fmt" + "net/http" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" ) type ( @@ -29,7 +29,7 @@ func ServeAllTracks(app *model.AppState) http.Handler { dbTracks, err := controller.GetAllTracks(app.DB) if err != nil { fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } for _, track := range dbTracks { @@ -39,23 +39,23 @@ func ServeAllTracks(app *model.AppState) http.Handler { }) } - w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(w) encoder.SetIndent("", "\t") err = encoder.Encode(tracks) - if err != nil { + if err != nil { fmt.Printf("WARN: Failed to serve all tracks: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } }) } func ServeTrack(app *model.AppState, track *model.Track) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false) if err != nil { fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } releases := []string{} @@ -63,15 +63,15 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler { releases = append(releases, release.ID) } - w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(w) encoder.SetIndent("", "\t") err = encoder.Encode(Track{ track, releases }) - if err != nil { + if err != nil { fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - }) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) } func CreateTrack(app *model.AppState) http.Handler { diff --git a/api/uploads.go b/api/uploads.go index 60ab7dd..4678f22 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -1,56 +1,56 @@ package api import ( - "arimelody-web/log" - "arimelody-web/model" - "bufio" - "encoding/base64" - "errors" - "fmt" - "os" - "path/filepath" - "strings" + "arimelody-web/log" + "arimelody-web/model" + "bufio" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strings" ) func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) { - split := strings.Split(*data, ";base64,") - header := split[0] - imageData, err := base64.StdEncoding.DecodeString(split[1]) - ext, _ := strings.CutPrefix(header, "data:image/") + split := strings.Split(*data, ";base64,") + header := split[0] + imageData, err := base64.StdEncoding.DecodeString(split[1]) + ext, _ := strings.CutPrefix(header, "data:image/") directory = filepath.Join(app.Config.DataDirectory, directory) - switch ext { - case "png": - case "jpg": - case "jpeg": - default: - return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg") - } + switch ext { + case "png": + case "jpg": + case "jpeg": + default: + return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg") + } filename = fmt.Sprintf("%s.%s", filename, ext) - // ensure directory exists - os.MkdirAll(directory, os.ModePerm) + // ensure directory exists + os.MkdirAll(directory, os.ModePerm) - imagePath := filepath.Join(directory, filename) - file, err := os.Create(imagePath) - if err != nil { - return "", err - } - defer file.Close() + imagePath := filepath.Join(directory, filename) + file, err := os.Create(imagePath) + if err != nil { + return "", err + } + defer file.Close() // TODO: generate compressed versions of image (512x512?) - buffer := bufio.NewWriter(file) - _, err = buffer.Write(imageData) - if err != nil { + buffer := bufio.NewWriter(file) + _, err = buffer.Write(imageData) + if err != nil { return "", nil - } + } - if err := buffer.Flush(); err != nil { + if err := buffer.Flush(); err != nil { return "", nil - } + } app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext) - return filename, nil + return filename, nil } diff --git a/controller/account.go b/controller/account.go index e4f5dc4..ab64ca5 100644 --- a/controller/account.go +++ b/controller/account.go @@ -1,10 +1,10 @@ package controller import ( - "arimelody-web/model" - "strings" + "arimelody-web/model" + "strings" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) { @@ -112,24 +112,24 @@ func DeleteAccount(db *sqlx.DB, accountID string) error { } func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) { - failAttempts := 0 - err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) - if err != nil { return false, err } - locked := false - if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS { - err = LockAccount(db, accountID) - if err != nil { return false, err } - locked = true - } - return locked, err + failAttempts := 0 + err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) + if err != nil { return false, err } + locked := false + if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS { + err = LockAccount(db, accountID) + if err != nil { return false, err } + locked = true + } + return locked, err } func LockAccount(db *sqlx.DB, accountID string) error { - _, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) - return err + _, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) + return err } func UnlockAccount(db *sqlx.DB, accountID string) error { - _, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) - return err + _, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) + return err } diff --git a/controller/artist.go b/controller/artist.go index 1a613aa..f086778 100644 --- a/controller/artist.go +++ b/controller/artist.go @@ -1,48 +1,48 @@ package controller import ( - "arimelody-web/model" + "arimelody-web/model" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) // DATABASE func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) { - var artist = model.Artist{} + var artist = model.Artist{} - err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id) - if err != nil { - return nil, err - } + err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id) + if err != nil { + return nil, err + } - return &artist, nil + return &artist, nil } func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) { - var artists = []*model.Artist{} + var artists = []*model.Artist{} - err := db.Select(&artists, "SELECT * FROM artist") - if err != nil { - return nil, err - } + err := db.Select(&artists, "SELECT * FROM artist") + if err != nil { + return nil, err + } - return artists, nil + return artists, nil } func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) { - var artists = []*model.Artist{} + var artists = []*model.Artist{} - err := db.Select(&artists, + err := db.Select(&artists, "SELECT * FROM artist "+ "WHERE id NOT IN "+ "(SELECT artist FROM musiccredit WHERE release=$1)", releaseID) - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } - return artists, nil + return artists, nil } func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) { @@ -54,9 +54,9 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model. if !show_hidden { query += "AND visible=true " } query += "ORDER BY release_date DESC" rows, err := db.Query(query, artistID) - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } defer rows.Close() type NamePrimary struct { @@ -102,13 +102,13 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model. func CreateArtist(db *sqlx.DB, artist *model.Artist) error { _, err := db.Exec( - "INSERT INTO artist (id, name, website, avatar) "+ + "INSERT INTO artist (id, name, website, avatar) "+ "VALUES ($1, $2, $3, $4)", - artist.ID, - artist.Name, - artist.Website, + artist.ID, + artist.Name, + artist.Website, artist.Avatar, - ) + ) if err != nil { return err } diff --git a/controller/config.go b/controller/config.go index 5a69d49..1d3cbbb 100644 --- a/controller/config.go +++ b/controller/config.go @@ -1,14 +1,14 @@ package controller import ( - "errors" - "fmt" - "os" - "strconv" + "errors" + "fmt" + "os" + "strconv" - "arimelody-web/model" + "arimelody-web/model" - "github.com/pelletier/go-toml/v2" + "github.com/pelletier/go-toml/v2" ) func GetConfig() model.Config { diff --git a/controller/invite.go b/controller/invite.go index f30db64..a7bde40 100644 --- a/controller/invite.go +++ b/controller/invite.go @@ -1,12 +1,12 @@ package controller import ( - "arimelody-web/model" - "math/rand" - "strings" - "time" + "arimelody-web/model" + "math/rand" + "strings" + "time" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") diff --git a/controller/ip.go b/controller/ip.go index 233d76a..cbc3054 100644 --- a/controller/ip.go +++ b/controller/ip.go @@ -1,10 +1,10 @@ package controller import ( - "arimelody-web/model" - "net/http" - "slices" - "strings" + "arimelody-web/model" + "net/http" + "slices" + "strings" ) // Returns the request's original IP address, resolving the `x-forwarded-for` diff --git a/controller/migrator.go b/controller/migrator.go index b970a1b..4b99b9c 100644 --- a/controller/migrator.go +++ b/controller/migrator.go @@ -1,11 +1,11 @@ package controller import ( - "fmt" - "os" - "time" + "fmt" + "os" + "time" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) const DB_VERSION int = 4 diff --git a/controller/qr.go b/controller/qr.go index 7ada0f8..6b04e69 100644 --- a/controller/qr.go +++ b/controller/qr.go @@ -1,13 +1,13 @@ package controller import ( - "bytes" - "encoding/base64" - "errors" - "fmt" - "image" - "image/color" - "image/png" + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "image/color" + "image/png" "github.com/skip2/go-qrcode" ) diff --git a/controller/release.go b/controller/release.go index 362669a..3dcad26 100644 --- a/controller/release.go +++ b/controller/release.go @@ -1,12 +1,12 @@ package controller import ( - "errors" - "fmt" + "errors" + "fmt" - "arimelody-web/model" + "arimelody-web/model" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) { diff --git a/controller/session.go b/controller/session.go index dce7ad0..b037575 100644 --- a/controller/session.go +++ b/controller/session.go @@ -1,17 +1,17 @@ package controller import ( - "database/sql" - "errors" - "fmt" - "net/http" - "strings" - "time" + "database/sql" + "errors" + "fmt" + "net/http" + "strings" + "time" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/log" + "arimelody-web/model" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) const TOKEN_LEN = 64 @@ -33,19 +33,19 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session } if session != nil { - if session.UserAgent != r.UserAgent() { - msg := "Session user agent mismatch. A cookie may have been hijacked!" - if session.Account != nil { - account, _ := GetAccountByID(app.DB, session.Account.ID) - msg += " (Account \"" + account.Username + "\")" - } - app.Log.Warn(log.TYPE_ACCOUNT, msg) - err = DeleteSession(app.DB, session.Token) - if err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session") - } - return nil, nil - } + if session.UserAgent != r.UserAgent() { + msg := "Session user agent mismatch. A cookie may have been hijacked!" + if session.Account != nil { + account, _ := GetAccountByID(app.DB, session.Account.ID) + msg += " (Account \"" + account.Username + "\")" + } + app.Log.Warn(log.TYPE_ACCOUNT, msg) + err = DeleteSession(app.DB, session.Token) + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session") + } + return nil, nil + } } } diff --git a/controller/totp.go b/controller/totp.go index 88f6bc3..076d3a1 100644 --- a/controller/totp.go +++ b/controller/totp.go @@ -1,20 +1,20 @@ package controller import ( - "arimelody-web/model" - "crypto/hmac" - "crypto/rand" - "crypto/sha1" - "encoding/base32" - "encoding/binary" - "fmt" - "math" - "net/url" - "os" - "strings" - "time" + "arimelody-web/model" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "math" + "net/url" + "os" + "strings" + "time" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) const TOTP_SECRET_LENGTH = 32 diff --git a/controller/track.go b/controller/track.go index fa4efc1..ee7581c 100644 --- a/controller/track.go +++ b/controller/track.go @@ -1,9 +1,9 @@ package controller import ( - "arimelody-web/model" + "arimelody-web/model" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) // DATABASE @@ -13,19 +13,19 @@ func GetTrack(db *sqlx.DB, id string) (*model.Track, error) { stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1") err := stmt.Get(&track, id) - if err != nil { + if err != nil { return nil, err - } + } return &track, nil } func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) { var tracks = []*model.Track{} - err := db.Select(&tracks, "SELECT * FROM musictrack") - if err != nil { + err := db.Select(&tracks, "SELECT * FROM musictrack") + if err != nil { return nil, err - } + } return tracks, nil } @@ -33,33 +33,33 @@ func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) { func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { var tracks = []*model.Track{} - err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)") - if err != nil { + err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)") + if err != nil { return nil, err - } + } return tracks, nil } func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) { - var tracks = []*model.Track{} + var tracks = []*model.Track{} - err := db.Select(&tracks, + err := db.Select(&tracks, "SELECT * FROM musictrack "+ "WHERE id NOT IN "+ "(SELECT track FROM musicreleasetrack WHERE release=$1)", releaseID) - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } - return tracks, nil + return tracks, nil } func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) { var releases = []*model.Release{} - err := db.Select(&releases, + err := db.Select(&releases, "SELECT id,title,type,release_date,artwork,buylink "+ "FROM musicrelease "+ "JOIN musicreleasetrack ON release=id "+ @@ -67,9 +67,9 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, "ORDER BY release_date", trackID, ) - if err != nil { + if err != nil { return nil, err - } + } type NamePrimary struct { Name string `json:"name"` @@ -114,14 +114,14 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { var tracks = []*model.Track{} - err := db.Select(&tracks, + err := db.Select(&tracks, "SELECT id, title, description, lyrics, preview_url FROM musictrack "+ "WHERE id NOT IN "+ "(SELECT track FROM musicreleasetrack)", ) - if err != nil { + if err != nil { return nil, err - } + } return tracks, nil } diff --git a/cursor/cursor.go b/cursor/cursor.go index 4ed59e3..56edb56 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,16 +1,16 @@ package cursor import ( - "arimelody-web/model" - "fmt" - "math/rand" - "net/http" - "strconv" - "strings" - "sync" - "time" + "arimelody-web/model" + "fmt" + "math/rand" + "net/http" + "strconv" + "strings" + "sync" + "time" - "github.com/gorilla/websocket" + "github.com/gorilla/websocket" ) type CursorClient struct { diff --git a/discord/discord.go b/discord/discord.go index d46f32d..0eb9b97 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -1,13 +1,13 @@ package discord import ( - "arimelody-web/model" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" + "arimelody-web/model" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" ) const API_ENDPOINT = "https://discord.com/api/v10" diff --git a/log/log.go b/log/log.go index 2d1c0c2..88d328b 100644 --- a/log/log.go +++ b/log/log.go @@ -1,11 +1,11 @@ package log import ( - "fmt" - "os" - "time" + "fmt" + "os" + "time" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) type ( diff --git a/main.go b/main.go index c0f6ee2..23c7dc4 100644 --- a/main.go +++ b/main.go @@ -1,33 +1,33 @@ package main import ( - "bufio" - "errors" - "fmt" - stdLog "log" - "math" - "math/rand" - "net" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" + "bufio" + "errors" + "fmt" + stdLog "log" + "math" + "math/rand" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" - "arimelody-web/admin" - "arimelody-web/api" - "arimelody-web/colour" - "arimelody-web/controller" - "arimelody-web/cursor" - "arimelody-web/log" - "arimelody-web/model" - "arimelody-web/templates" - "arimelody-web/view" + "arimelody-web/admin" + "arimelody-web/api" + "arimelody-web/colour" + "arimelody-web/controller" + "arimelody-web/cursor" + "arimelody-web/log" + "arimelody-web/model" + "arimelody-web/templates" + "arimelody-web/view" - "github.com/jmoiron/sqlx" - _ "github.com/lib/pq" - "golang.org/x/crypto/bcrypt" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "golang.org/x/crypto/bcrypt" ) // used for database migrations @@ -282,7 +282,7 @@ func main() { account.ID, email, account.CreatedAt, - account.Locked, + account.Locked, ) } return diff --git a/model/account.go b/model/account.go index ad65b82..67424b7 100644 --- a/model/account.go +++ b/model/account.go @@ -1,23 +1,23 @@ package model import ( - "database/sql" - "time" + "database/sql" + "time" ) const COOKIE_TOKEN string = "AM_SESSION" const MAX_LOGIN_FAIL_ATTEMPTS int = 3 type ( - Account struct { - ID string `json:"id" db:"id"` - Username string `json:"username" db:"username"` - Password string `json:"password" db:"password"` - Email sql.NullString `json:"email" db:"email"` - AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - FailAttempts int `json:"fail_attempts" db:"fail_attempts"` - Locked bool `json:"locked" db:"locked"` + Account struct { + ID string `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Password string `json:"password" db:"password"` + Email sql.NullString `json:"email" db:"email"` + AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + FailAttempts int `json:"fail_attempts" db:"fail_attempts"` + Locked bool `json:"locked" db:"locked"` Privileges []AccountPrivilege `json:"privileges"` } diff --git a/model/appstate.go b/model/appstate.go index 233e0db..a910a29 100644 --- a/model/appstate.go +++ b/model/appstate.go @@ -1,7 +1,7 @@ package model import ( - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" "arimelody-web/log" ) diff --git a/model/artist.go b/model/artist.go index 63871c7..746a7dd 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,17 +1,17 @@ package model type ( - Artist struct { - ID string `json:"id"` - Name string `json:"name"` - Website string `json:"website"` - Avatar string `json:"avatar"` - } + Artist struct { + ID string `json:"id"` + Name string `json:"name"` + Website string `json:"website"` + Avatar string `json:"avatar"` + } ) func (artist Artist) GetAvatar() string { - if artist.Avatar == "" { - return "/img/default-avatar.png" - } - return artist.Avatar + if artist.Avatar == "" { + return "/img/default-avatar.png" + } + return artist.Avatar } diff --git a/model/link.go b/model/link.go index 1a5bb8f..ba83e22 100644 --- a/model/link.go +++ b/model/link.go @@ -1,16 +1,16 @@ package model import ( - "regexp" - "strings" + "regexp" + "strings" ) type Link struct { - Name string `json:"name"` - URL string `json:"url"` + Name string `json:"name"` + URL string `json:"url"` } func (link Link) NormaliseName() string { - rgx := regexp.MustCompile(`[^a-z0-9\-]`) - return rgx.ReplaceAllString(strings.ToLower(link.Name), "") + rgx := regexp.MustCompile(`[^a-z0-9\-]`) + return rgx.ReplaceAllString(strings.ToLower(link.Name), "") } diff --git a/model/release.go b/model/release.go index afaacca..e64317b 100644 --- a/model/release.go +++ b/model/release.go @@ -1,9 +1,9 @@ package model import ( - "html/template" - "strings" - "time" + "html/template" + "strings" + "time" ) type ( @@ -73,23 +73,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string { names = append(names, credit.Artist.Name) } - return names + return names } func (release Release) PrintArtists(only_primary bool, ampersand bool) string { names := release.GetUniqueArtistNames(only_primary) - if len(names) == 0 { - return "Unknown Artist" - } else if len(names) == 1 { - return names[0] - } + if len(names) == 0 { + return "Unknown Artist" + } else if len(names) == 1 { + return names[0] + } - if ampersand { - res := strings.Join(names[:len(names)-1], ", ") - res += " & " + names[len(names)-1] - return res - } else { - return strings.Join(names[:], ", ") - } + if ampersand { + res := strings.Join(names[:len(names)-1], ", ") + res += " & " + names[len(names)-1] + return res + } else { + return strings.Join(names[:], ", ") + } } diff --git a/model/release_test.go b/model/release_test.go index 11a58a1..b0ddaf5 100644 --- a/model/release_test.go +++ b/model/release_test.go @@ -1,8 +1,8 @@ package model import ( - "testing" - "time" + "testing" + "time" ) func Test_Release_DescriptionHTML(t *testing.T) { diff --git a/model/session.go b/model/session.go index de016e1..7382de3 100644 --- a/model/session.go +++ b/model/session.go @@ -1,8 +1,8 @@ package model import ( - "database/sql" - "time" + "database/sql" + "time" ) type Session struct { diff --git a/model/totp.go b/model/totp.go index cfad10a..108dae5 100644 --- a/model/totp.go +++ b/model/totp.go @@ -1,7 +1,7 @@ package model import ( - "time" + "time" ) type TOTP struct { diff --git a/model/track.go b/model/track.go index ca54ddd..deaf086 100644 --- a/model/track.go +++ b/model/track.go @@ -1,20 +1,20 @@ package model import ( - "html/template" - "strings" + "html/template" + "strings" ) type ( - Track struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` + Track struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` Lyrics string `json:"lyrics" db:"lyrics"` - PreviewURL string `json:"previewURL" db:"preview_url"` + PreviewURL string `json:"previewURL" db:"preview_url"` Number int - } + } ) func (track Track) GetDescriptionHTML() template.HTML { diff --git a/templates/templates.go b/templates/templates.go index 8d1a5ca..752c78d 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -1,8 +1,8 @@ package templates import ( - "html/template" - "path/filepath" + "html/template" + "path/filepath" ) var IndexTemplate = template.Must(template.ParseFiles( diff --git a/view/music.go b/view/music.go index dfe884e..8ed5279 100644 --- a/view/music.go +++ b/view/music.go @@ -1,13 +1,13 @@ package view import ( - "fmt" - "net/http" - "os" + "fmt" + "net/http" + "os" - "arimelody-web/controller" - "arimelody-web/model" - "arimelody-web/templates" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/templates" ) // HTTP HANDLER METHODS From 37fa1f4fa878fbd21cbe4fbb0bcc6933c2194203 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 29 Apr 2025 23:42:08 +0100 Subject: [PATCH 20/34] and she never dealt with indentation issues ever again --- .editorconfig | 7 +++++++ main.go | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a882442 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 diff --git a/main.go b/main.go index 23c7dc4..29539ac 100644 --- a/main.go +++ b/main.go @@ -223,7 +223,7 @@ func main() { code := controller.GenerateTOTP(totp.Secret, 0) fmt.Printf("%s\n", code) return - + case "cleanTOTP": err := controller.DeleteUnconfirmedTOTPs(app.DB) if err != nil { @@ -346,7 +346,7 @@ func main() { if !strings.HasPrefix(res, "y") { return } - + err = controller.DeleteAccount(app.DB, account.ID) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) From 76cf1bb0d53c5f394e69860bb1325b0e6a3551bc Mon Sep 17 00:00:00 2001 From: ari melody Date: Wed, 30 Apr 2025 18:21:47 +0100 Subject: [PATCH 21/34] log IP address for account locks :troll: --- admin/http.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/admin/http.go b/admin/http.go index 76eb5f2..245a152 100644 --- a/admin/http.go +++ b/admin/http.go @@ -283,7 +283,7 @@ func loginHandler(app *model.AppState) http.Handler { err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) - if locked := handleFailedLogin(app, account); locked { + if locked := handleFailedLogin(app, account, r); locked { controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") } else { controller.SetSessionError(app.DB, session, "Invalid username or password.") @@ -389,7 +389,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { } if totpMethod == nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) - if locked := handleFailedLogin(app, session.AttemptAccount); locked { + if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") controller.SetSessionAttemptAccount(app.DB, session, nil) http.Redirect(w, r, "/admin", http.StatusFound) @@ -514,7 +514,7 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler { }) } -func handleFailedLogin(app *model.AppState, account *model.Account) bool { +func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { locked, err := controller.IncrementAccountFails(app.DB, account.ID) if err != nil { fmt.Fprintf( @@ -532,9 +532,10 @@ func handleFailedLogin(app *model.AppState, account *model.Account) bool { if locked { app.Log.Warn( log.TYPE_ACCOUNT, - "Account \"%s\" was locked: %d failed login attempts", + "Account \"%s\" was locked: %d failed login attempts (IP: %s)", account.Username, model.MAX_LOGIN_FAIL_ATTEMPTS, + controller.ResolveIP(app, r), ) } return locked From 35e149e186fbaa8f0e5f4a5429c61e5e9289eeb2 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 5 May 2025 17:54:44 +0100 Subject: [PATCH 22/34] oops --- schema-migration/000-init.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schema-migration/000-init.sql b/schema-migration/000-init.sql index 2174109..90385ac 100644 --- a/schema-migration/000-init.sql +++ b/schema-migration/000-init.sql @@ -18,9 +18,9 @@ CREATE TABLE arimelody.account ( password TEXT NOT NULL, email TEXT, avatar_url TEXT, - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, fail_attempts INT NOT NULL DEFAULT 0, - locked BOOLEAN DEFAULT false, + locked BOOLEAN DEFAULT false ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); From 30c4252e40a5d2671ae78288a85695f855a71a7a Mon Sep 17 00:00:00 2001 From: ari melody Date: Wed, 21 May 2025 15:49:21 +0100 Subject: [PATCH 23/34] hamburger dropdown: match theme background colour --- public/style/header.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/style/header.css b/public/style/header.css index 48971b5..531649a 100644 --- a/public/style/header.css +++ b/public/style/header.css @@ -154,7 +154,7 @@ header ul li a:hover { flex-direction: column; gap: 1rem; border-bottom: 1px solid #888; - background: #080808; + background: var(--background); display: none; } From da1cd0204e37ebae2f526d6f8a8f5766c19daa2c Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 23 May 2025 12:23:05 +0100 Subject: [PATCH 24/34] update socials and projects --- views/index.html | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/views/index.html b/views/index.html index ba0ca15..f18d87a 100644 --- a/views/index.html +++ b/views/index.html @@ -91,14 +91,17 @@
  • twitch
  • -
  • - spotify -
  • bandcamp
  • - github + codeberg +
  • +
  • + bluesky +
  • +
  • + discord
@@ -106,6 +109,11 @@ projects i've worked on 🛠️