Compare commits

...
Sign in to create a new pull request.

56 commits

Author SHA1 Message Date
02905b9c47
Merge remote-tracking branch 'origin/dev' into dev 2025-08-01 00:33:19 +01:00
90ac0487e3
update web buttons 2025-08-01 00:29:47 +01:00
1896633fd1
clear expired sessions on server start
why wasn't i doing this before?? 😭
2025-07-18 23:38:01 +01:00
05ab207a1f
live banner: fix horizontal alignment 2025-07-05 18:53:57 +01:00
7a354cddf5
update some more links! 2025-06-22 18:05:21 +01:00
375ae84ae3
update some arimelody.me references to .space 2025-06-22 18:00:08 +01:00
581273370d
improvements to LIVE tracker 2025-06-17 02:03:53 +01:00
9274796729
early implementation of ari melody LIVE tracker 2025-06-17 01:15:08 +01:00
f7b3faf8e8
move source link from header to footer 2025-06-16 22:41:54 +01:00
69e2e22e47
homepage rework for socials and projects 2025-06-16 20:32:46 +01:00
fe84a59326
add thermia web button 2025-06-15 02:51:23 +01:00
da1cd0204e
update socials and projects 2025-05-23 12:23:05 +01:00
30c4252e40
hamburger dropdown: match theme background colour 2025-05-21 15:49:21 +01:00
35e149e186
oops 2025-05-05 17:55:42 +01:00
76cf1bb0d5
log IP address for account locks 🧌 2025-04-30 18:21:47 +01:00
37fa1f4fa8
and she never dealt with indentation issues ever again 2025-04-29 23:42:08 +01:00
23a02617f9
fix indentation (tabs to 4 spaces) (oops) 2025-04-29 23:25:32 +01:00
fe4a788898
update cli utility docs 2025-04-29 23:22:48 +01:00
562ed2e015
session validation/invalidation 2025-04-29 16:31:39 +01:00
1c0e541c89
lock accounts after enough failed login attempts 2025-04-29 11:32:48 +01:00
5cc9a1ca76
clear cursor display when shutting down 2025-03-31 13:44:29 +01:00
ed86aff2a2
fix config not saving with broken listeners, fix cursor ReferenceError 2025-03-31 13:36:02 +01:00
6db35b2f99
model function unit tests! 2025-03-24 19:39:10 +00:00
e5ae167550
incredibly important wii update 2025-03-15 22:47:37 +00:00
51283c1a4f
oops 2025-03-15 22:37:23 +00:00
ecda8dde24
hell yeah i love canvas rendering 2025-03-15 17:54:58 +00:00
3f3164bc15
and the goddess spoke: don't make this silly mistake again 2025-03-14 20:55:09 +00:00
7eac476558
random scripts are silly. MAKEFILES are where it's at 2025-03-14 20:47:58 +00:00
4b3c8f9449
lol cursor is multiplayer now 2025-03-14 20:42:44 +00:00
a797e82a68
waow i really like doing config overhauls don't i
+ cursor improvements
2025-03-14 16:26:30 +00:00
739390d7f5
funny cursor teehee 2025-03-14 00:33:59 +00:00
0fc6c9f86d
update public gpg key 2025-02-19 01:59:12 +00:00
3cb8d2940a
fix white-space on log message 2025-02-08 12:25:19 +00:00
edb4d7df3a
trim extra IPs from x-forwarded-for header 2025-02-08 12:16:03 +00:00
23dbbf26e3
handle x-forwarded-for in IP logs 2025-02-08 12:15:50 +00:00
d9b71381b0
logs in use; new audit log panel! 2025-02-07 16:40:58 +00:00
1397274967
log search implementation 2025-02-07 12:41:25 +00:00
01c285de1b
merge 'dev' into feature/auditlogs 2025-02-07 12:08:35 +00:00
b7938b02e8
fix session handling on public API 2025-02-07 12:06:52 +00:00
aa144b719a
audit log basic db implementation 2025-02-06 13:45:33 +00:00
e80a6753a5
create log class, edit fatal-but-not-really logs 2025-02-06 12:32:51 +00:00
8ccf6f242b
remove redundant console.log 2025-02-06 09:14:03 +00:00
e07c68a564
music gateway: minor css tweaks 2025-02-04 09:39:56 +00:00
e6c5ecc469
a11y: ARIA text for footer line
+ fixed indentation on some view sources
2025-02-04 09:29:44 +00:00
4b36603b89
add missing command arg to readme 2025-01-27 19:57:46 +00:00
64780611de
add license (MIT) 2025-01-27 18:30:50 +00:00
b7fce821b4
update prideflag.js 2025-01-27 18:30:27 +00:00
70825ae875
fixed viewing invisible releases with admin session 2025-01-27 00:27:03 +00:00
1efe52a8cb
fixed critical login TOTP bypass bug! whoops!!!!! 2025-01-27 00:04:08 +00:00
2e93c3c5e5
fix typo in schema migrations...oops! 2025-01-26 20:51:37 +00:00
2fc855450e
update bundler script, rename schema-migration directory 2025-01-26 20:47:48 +00:00
b91b6e7ce0
polished up TOTP enrolment 2025-01-26 20:37:20 +00:00
d2ac66a81a
merge feature/accountsettings into dev 2025-01-26 20:09:55 +00:00
3450d879ac
QR codes complete, account settings finished!
+ refactored templates a little; this might need more work!
2025-01-26 20:09:18 +00:00
1edc051ae2
fixed GetTOTP, started rough QR code implementation
GetTOTP handles TOTP method retrieval for confirmation and deletion.

QR code implementation looks like it's gonna suck, so might end up
using a library for this later.
2025-01-26 00:48:19 +00:00
2ee874b2ca
merge main into dev 2025-01-22 11:40:08 +00:00
93 changed files with 3581 additions and 990 deletions

7
.editorconfig Normal file
View file

@ -0,0 +1,7 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ docker-compose*.yml
!docker-compose.example.yml
config*.toml
arimelody-web
arimelody-web.tar.gz

22
LICENSE.md Normal file
View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025-present ari melody
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

12
Makefile Normal file
View file

@ -0,0 +1,12 @@
EXEC = arimelody-web
.PHONY: $(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/
clean:
rm $(EXEC) $(EXEC).tar.gz

View file

@ -42,7 +42,11 @@ need to be up for this, making this ideal for some offline maintenance.
- `listTOTP <username>`: Lists an account's TOTP methods.
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
- `cleanTOTP`: Cleans up unconfirmed (dangling) TOTP methods.
- `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts.
- `deleteAccount <username>`: Deletes an account with a given `username`.
- `lockAccount <username>`: Locks the account under `username`.
- `unlockAccount <username>`: Unlocks the account under `username`.
- `logs`: Shows system logs.

View file

@ -1,16 +1,17 @@
package admin
import (
"fmt"
"net/http"
"net/url"
"os"
"time"
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"arimelody-web/controller"
"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 {
@ -63,7 +64,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Message = sessionMessage
session.Error = sessionError
err = pages["account"].Execute(w, accountResponse{
err = accountTemplate.Execute(w, accountResponse{
Session: session,
TOTPs: totps,
})
@ -114,6 +115,8 @@ 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(app, r))
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
@ -133,7 +136,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
return
}
if !r.Form.Has("password") || !r.Form.Has("totp") {
if !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
@ -142,33 +145,12 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
// check password
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
fmt.Printf(
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
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
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp"))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totpMethod == nil {
fmt.Printf(
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
}
err = controller.DeleteAccount(app.DB, session.Account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
@ -177,11 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
return
}
fmt.Printf(
"[%s] INFO: Account \"%s\" deleted by user request.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
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, "")
@ -190,6 +168,13 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
})
}
type totpConfirmData struct {
Session *model.Session
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
}
func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
@ -199,7 +184,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session })
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -212,12 +197,6 @@ func totpSetupHandler(app *model.AppState) http.Handler {
return
}
type totpSetupData struct {
Session *model.Session
TOTP *model.TOTP
NameEscaped string
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -242,7 +221,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session })
err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -250,10 +229,17 @@ func totpSetupHandler(app *model.AppState) http.Handler {
return
}
err = pages["totp-confirm"].Execute(w, totpSetupData{
qrBase64Image, err := controller.GenerateQRCode(
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
@ -269,11 +255,6 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
return
}
type totpConfirmData struct {
Session *model.Session
TOTP *model.TOTP
}
session := r.Context().Value("session").(*model.Session)
err := r.ParseForm()
@ -294,7 +275,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
@ -304,19 +285,41 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
return
}
qrBase64Image, err := controller.GenerateQRCode(
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
if code != confirmCode {
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if code != confirmCodeOffset {
controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.")
err = pages["totp-confirm"].Execute(w, totpConfirmData{
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
}
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
@ -330,12 +333,11 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
return
}
name := r.URL.Path
fmt.Printf("%s\n", name);
if len(name) == 0 {
if len(r.URL.Path) < 2 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.URL.Path[1:]
session := r.Context().Value("session").(*model.Session)
@ -359,6 +361,8 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)

View file

@ -1,9 +1,9 @@
package admin
import (
"fmt"
"net/http"
"strings"
"fmt"
"net/http"
"strings"
"arimelody-web/model"
"arimelody-web/controller"
@ -39,7 +39,7 @@ func serveArtist(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
err = pages["artist"].Execute(w, ArtistResponse{
err = artistTemplate.Execute(w, ArtistResponse{
Session: session,
Artist: artist,
Credits: credits,

View file

@ -1,6 +1,6 @@
<dialog id="addcredit">
<header>
<h2>Add artist credit</h2>
<h2>Add Artist Credit</h2>
</header>
<ul>

View file

@ -7,7 +7,7 @@
<h3 class="release-title">
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
<small>
<span title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
{{if not .Visible}}(hidden){{end}}
</small>
</h3>

View file

@ -1,6 +1,6 @@
<dialog id="addtrack">
<header>
<h2>Add track</h2>
<h2>Add Track</h2>
</header>
<ul>

View file

@ -1,39 +1,54 @@
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/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 {
mux := http.NewServeMux()
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
}))
mux.Handle("/login", loginHandler(app))
mux.Handle("/logout", requireAccount(app, logoutHandler(app)))
mux.Handle("/totp", loginTOTPHandler(app))
mux.Handle("/logout", requireAccount(logoutHandler(app)))
mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(app, accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app))))
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app))))
mux.Handle("/release/", requireAccount(app, http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(app, http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/", requireAccount(app, AdminIndexHandler(app)))
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
// response wrapper to make sure a session cookie exists
return enforceSession(app, mux)
@ -76,7 +91,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
Tracks []*model.Track
}
err = pages["index"].Execute(w, IndexData{
err = indexTemplate.Execute(w, IndexData{
Session: session,
Releases: releases,
Artists: artists,
@ -105,7 +120,7 @@ func registerAccountHandler(app *model.AppState) http.Handler {
}
render := func() {
err := pages["register"].Execute(w, registerData{ Session: session })
err := registerTemplate.Execute(w, registerData{ Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -186,15 +201,12 @@ func registerAccountHandler(app *model.AppState) http.Handler {
return
}
fmt.Printf(
"[%s]: Account registered: %s (%s)\n",
time.Now().Format(time.UnixDate),
account.Username,
account.ID,
)
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 { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
}
// registration success!
controller.SetSessionAccount(app.DB, session, &account)
@ -218,7 +230,7 @@ func loginHandler(app *model.AppState) http.Handler {
}
render := func() {
err := pages["login"].Execute(w, loginData{ Session: session })
err := loginTemplate.Execute(w, loginData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -232,7 +244,6 @@ func loginHandler(app *model.AppState) http.Handler {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render()
return
}
@ -243,23 +254,15 @@ func loginHandler(app *model.AppState) http.Handler {
return
}
// new accounts won't have TOTP methods at first. there should be a
// second phase of login that prompts the user for a TOTP *only*
// if that account has a TOTP method.
// TODO: login phases (username & password -> TOTP)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
}
credentials := LoginRequest{
Username: r.Form.Get("username"),
Password: r.Form.Get("password"),
TOTP: r.Form.Get("totp"),
if !r.Form.Has("username") || !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
account, err := controller.GetAccountByUsername(app.DB, credentials.Username)
username := r.FormValue("username")
password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
@ -271,81 +274,145 @@ func loginHandler(app *model.AppState) http.Handler {
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
fmt.Printf(
"[%s] INFO: Account \"%s\" attempted login with incorrect password.\n",
time.Now().Format(time.UnixDate),
account.Username,
)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
var totpMethod *model.TOTP
if len(credentials.TOTP) == 0 {
// check if user has TOTP
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
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, 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.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
type loginTOTPData struct {
Session *model.Session
Username string
Password string
}
err = pages["login-totp"].Execute(w, loginTOTPData{
Session: session,
Username: credentials.Username,
Password: credentials.Password,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
} else {
totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if totpMethod == nil {
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
if totpMethod != nil {
fmt.Printf(
"[%s] INFO: Account \"%s\" logged in with method \"%s\"\n",
time.Now().Format(time.UnixDate),
account.Username,
totpMethod.Name,
)
} else {
fmt.Printf(
"[%s] INFO: Account \"%s\" logged in\n",
time.Now().Format(time.UnixDate),
account.Username,
)
}
// TODO: log login activity to user
// login success!
controller.SetSessionAccount(app.DB, session, account)
// TODO: log login activity to user
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)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
type loginTOTPData struct {
Session *model.Session
}
render := func() {
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
r.ParseForm()
if !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
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(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
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, 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)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
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 {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
@ -373,7 +440,7 @@ func logoutHandler(app *model.AppState) http.Handler {
Path: "/",
})
err = pages["logout"].Execute(w, nil)
err = logoutTemplate.Execute(w, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -381,7 +448,7 @@ func logoutHandler(app *model.AppState) http.Handler {
})
}
func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc {
func requireAccount(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account == nil {
@ -416,30 +483,13 @@ func staticHandler() http.Handler {
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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)
return
}
var session *model.Session
if sessionCookie != nil {
// fetch existing session
session, err = controller.GetSession(app.DB, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session != nil {
// TODO: consider running security checks here (i.e. user agent mismatches)
}
}
if session == nil {
// create a new session
session, err = controller.CreateSession(app.DB, r.UserAgent())
@ -463,3 +513,30 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx))
})
}
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(
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 (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
}
return locked
}

67
admin/logshttp.go Normal file
View file

@ -0,0 +1,67 @@
package admin
import (
"arimelody-web/log"
"arimelody-web/model"
"fmt"
"net/http"
"os"
"strings"
)
func logsHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
levelFilter := []log.LogLevel{}
typeFilter := []string{}
query := r.URL.Query().Get("q")
for key, value := range r.URL.Query() {
if strings.HasPrefix(key, "level-") && value[0] == "on" {
m := map[string]log.LogLevel{
"info": log.LEVEL_INFO,
"warn": log.LEVEL_WARN,
}
level, ok := m[strings.TrimPrefix(key, "level-")]
if ok {
levelFilter = append(levelFilter, level)
}
continue
}
if strings.HasPrefix(key, "type-") && value[0] == "on" {
typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-")))
continue
}
}
logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type LogsResponse struct {
Session *model.Session
Logs []*log.Log
}
err = logsTemplate.Execute(w, LogsResponse{
Session: session,
Logs: logs,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}

View file

@ -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 {
@ -22,7 +22,7 @@ func serveRelease(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -60,7 +60,7 @@ func serveRelease(app *model.AppState) http.Handler {
Release *model.Release
}
err = pages["release"].Execute(w, ReleaseResponse{
err = releaseTemplate.Execute(w, ReleaseResponse{
Session: session,
Release: release,
})
@ -74,7 +74,7 @@ func serveRelease(app *model.AppState) http.Handler {
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := components["editcredits"].Execute(w, release)
err := editCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -86,7 +86,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -97,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["addcredit"].Execute(w, response{
err = addCreditTemplate.Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
@ -113,7 +113,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -123,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["newcredit"].Execute(w, artist)
err = newCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -134,7 +134,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := components["editlinks"].Execute(w, release)
err := editLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -151,7 +151,7 @@ func serveEditTracks(release *model.Release) http.Handler {
Add func(a int, b int) int
}
err := components["edittracks"].Execute(w, editTracksData{
err := editTracksTemplate.Execute(w, editTracksData{
Release: release,
Add: func(a, b int) int { return a + b },
})
@ -166,7 +166,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -177,7 +177,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["addtrack"].Execute(w, response{
err = addTrackTemplate.Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
@ -204,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["newtrack"].Execute(w, track)
err = newTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

86
admin/static/logs.css Normal file
View file

@ -0,0 +1,86 @@
main {
width: min(1080px, calc(100% - 2em))!important
}
form {
margin: 1em 0;
}
div#search {
display: flex;
}
#search input {
margin: 0;
flex-grow: 1;
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
#search button {
padding: 0 .5em;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
form #filters p {
margin: .5em 0 0 0;
}
form #filters label {
display: inline;
}
form #filters input {
margin-right: 1em;
display: inline;
}
#logs {
width: 100%;
border-collapse: collapse;
}
#logs tr {
}
#logs tr td {
border-bottom: 1px solid #8888;
}
#logs tr td:nth-child(even) {
background: #00000004;
}
#logs th, #logs td {
padding: .4em .8em;
}
td, th {
width: 1%;
text-align: left;
white-space: nowrap;
}
td.log-level,
th.log-level,
td.log-type,
th.log-type {
text-align: center;
}
td.log-content,
td.log-content {
width: 100%;
white-space: collapse;
}
.log:hover {
background: #fff8;
}
.log.warn {
background: #ffe86a;
}
.log.warn:hover {
background: #ffec81;
}

View file

@ -1,80 +1,125 @@
package admin
import (
"html/template"
"path/filepath"
"arimelody-web/log"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
)
var pages = map[string]*template.Template{
"index": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "index.html"),
)),
var indexTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "index.html"),
))
"login": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login.html"),
)),
"login-totp": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login-totp.html"),
)),
"register": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "register.html"),
)),
"logout": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"),
)),
"account": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"),
)),
"totp-setup": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-setup.html"),
)),
"totp-confirm": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-confirm.html"),
)),
var loginTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login.html"),
))
var loginTOTPTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login-totp.html"),
))
var registerTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "register.html"),
))
var logoutTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"),
))
var accountTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"),
))
var totpSetupTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-setup.html"),
))
var totpConfirmTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-confirm.html"),
))
"release": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"),
)),
"artist": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"),
)),
"track": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "edit-track.html"),
)),
}
var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
"parseLevel": func(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
},
"titleCase": func(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
},
"lower": func(str string) string { return strings.ToLower(str) },
"prettyTime": func(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
},
}).ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logs.html"),
))
var components = map[string]*template.Template{
"editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))),
"addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))),
"newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))),
var releaseTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"),
))
var artistTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"),
))
var trackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "edit-track.html"),
))
"editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))),
var editCreditsTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "editcredits.html"),
))
var addCreditTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "addcredit.html"),
))
var newCreditTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "newcredit.html"),
))
"edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))),
"addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))),
"newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))),
}
var editLinksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "links", "editlinks.html"),
))
var editTracksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "edittracks.html"),
))
var addTrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "addtrack.html"),
))
var newTrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "newtrack.html"),
))

View file

@ -1,9 +1,9 @@
package admin
import (
"fmt"
"net/http"
"strings"
"fmt"
"net/http"
"strings"
"arimelody-web/model"
"arimelody-web/controller"
@ -39,7 +39,7 @@ func serveTrack(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
err = pages["track"].Execute(w, TrackResponse{
err = trackTemplate.Execute(w, TrackResponse{
Session: session,
Track: track,
Releases: releases,

View file

@ -23,7 +23,14 @@
<div class="nav-item">
<a href="/admin">home</a>
</div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/logs">logs</a>
</div>
{{end}}
<div class="flex-fill"></div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>

View file

@ -26,14 +26,19 @@ input {
{{define "content"}}
<main>
<form action="/admin/login" method="POST" id="login-totp">
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/totp" method="POST" id="login-totp">
<h1>Two-Factor Authentication</h1>
<div>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
<input type="hidden" name="username" value="{{.Username}}">
<input type="hidden" name="password" value="{{.Password}}">
</div>
<button type="submit" class="save">Login</button>

68
admin/views/logs.html Normal file
View file

@ -0,0 +1,68 @@
{{define "head"}}
<title>Audit Logs - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<link rel="stylesheet" href="/admin/static/logs.css">
{{end}}
{{define "content"}}
<main>
<h1>Audit Logs</h1>
<form action="/admin/logs" method="GET">
<div id="search">
<input type="text" name="q" value="" placeholder="Filter by message...">
<button type="submit" class="save">Search</button>
</div>
<div id="filters">
<div>
<p>Level:</p>
<label for="level-info">Info</label>
<input type="checkbox" name="level-info" id="level-info">
<label for="level-warn">Warning</label>
<input type="checkbox" name="level-warn" id="level-warn">
</div>
<div>
<p>Type:</p>
<label for="type-account">Account</label>
<input type="checkbox" name="type-account" id="type-account">
<label for="type-music">Music</label>
<input type="checkbox" name="type-music" id="type-music">
<label for="type-artist">Artist</label>
<input type="checkbox" name="type-artist" id="type-artist">
<label for="type-blog">Blog</label>
<input type="checkbox" name="type-blog" id="type-blog">
<label for="type-artwork">Artwork</label>
<input type="checkbox" name="type-artwork" id="type-artwork">
<label for="type-files">Files</label>
<input type="checkbox" name="type-files" id="type-files">
<label for="type-misc">Misc</label>
<input type="checkbox" name="type-misc" id="type-misc">
</div>
</div>
</form>
<hr>
<table id="logs">
<thead>
<tr>
<th class="log-time">Time</th>
<th class="log-level">Level</th>
<th class="log-type">Type</th>
<th class="log-content">Message</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr class="log {{lower (parseLevel .Level)}}">
<td class="log-time">{{prettyTime .CreatedAt}}</td>
<td class="log-level">{{parseLevel .Level}}</td>
<td class="log-type">{{titleCase .Type}}</td>
<td class="log-content">{{.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
</main>
{{end}}

View file

@ -3,6 +3,9 @@
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
.qr-code {
border: 1px solid #8888;
}
code {
user-select: all;
}
@ -16,15 +19,26 @@ code {
{{end}}
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
<p><strong>Your TOTP secret: </strong><code>{{.TOTP.Secret}}</code></p>
<!-- TODO: TOTP secret QR codes -->
{{if .QRBase64Image}}
<img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code">
<p>
Please store this into your two-factor authentication app or
password manager, then enter your code below:
Scan the QR code above into your authentication app or password manager,
then enter your 2FA code below.
</p>
<p>
If the QR code does not work, you may also enter this secret code:
</p>
{{else}}
<p>
Paste the below secret code into your authentication app or password manager,
then enter your 2FA code below:
</p>
{{end}}
<p><code>{{.TOTP.Secret}}</code></p>
<label for="totp">TOTP:</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>

View file

@ -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 {
@ -27,7 +27,7 @@ func Handler(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -38,10 +38,10 @@ func Handler(app *model.AppState) http.Handler {
ServeArtist(app, artist).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/artist/{id} (admin)
requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r)
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin)
requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r)
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -53,7 +53,7 @@ func Handler(app *model.AppState) http.Handler {
ServeAllArtists(app).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/artist (admin)
requireAccount(app, CreateArtist(app)).ServeHTTP(w, r)
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -69,7 +69,7 @@ func Handler(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -80,10 +80,10 @@ func Handler(app *model.AppState) http.Handler {
ServeRelease(app, release).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/music/{id} (admin)
requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r)
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin)
requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r)
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -95,7 +95,7 @@ func Handler(app *model.AppState) http.Handler {
ServeCatalog(app).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/music (admin)
requireAccount(app, CreateRelease(app)).ServeHTTP(w, r)
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -111,7 +111,7 @@ func Handler(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -119,13 +119,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track/{id} (admin)
requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r)
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/track/{id} (admin)
requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r)
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin)
requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r)
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -134,19 +134,15 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track (admin)
requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r)
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/track (admin)
requireAccount(app, CreateTrack(app)).ServeHTTP(w, r)
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}))
return mux
}
func requireAccount(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := getSession(app, r)
if err != nil {
@ -154,7 +150,15 @@ func requireAccount(app *model.AppState, next http.Handler) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session.Account == nil {
ctx := context.WithValue(r.Context(), "session", session)
mux.ServeHTTP(w, r.WithContext(ctx))
})
}
func requireAccount(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session == nil || session.Account == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}

View file

@ -1,65 +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/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 {
@ -73,21 +74,23 @@ 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
@ -112,12 +115,16 @@ func CreateArtist(app *model.AppState) http.Handler {
return
}
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username)
w.WriteHeader(http.StatusCreated)
})
}
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
fmt.Printf("WARN: Failed to update artist: %s\n", err)
@ -158,11 +165,15 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
})
}
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteArtist(app.DB, artist.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
@ -172,5 +183,7 @@ func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
})
}

View file

@ -1,25 +1,32 @@
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/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 {
session := r.Context().Value("session").(*model.Session)
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)
return
}
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
@ -109,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 {
@ -183,10 +190,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
func CreateRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
@ -220,6 +224,8 @@ func CreateRelease(app *model.AppState) http.Handler {
return
}
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
encoder := json.NewEncoder(w)
@ -234,6 +240,8 @@ func CreateRelease(app *model.AppState) http.Handler {
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if r.URL.Path == "/" {
http.NotFound(w, r)
return
@ -298,11 +306,15 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
if err != nil {
@ -319,11 +331,15 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type creditJSON struct {
Artist string
Role string
@ -360,15 +376,14 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var links = []*model.Link{}
err := json.NewDecoder(r.Body).Decode(&links)
@ -386,11 +401,15 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteRelease(app.DB, release.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
@ -400,5 +419,7 @@ func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
})
}

View file

@ -1,12 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"encoding/json"
"fmt"
"net/http"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
type (
@ -28,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 {
@ -38,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{}
@ -62,23 +63,20 @@ 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var track model.Track
err := json.NewDecoder(r.Body).Decode(&track)
@ -99,6 +97,8 @@ func CreateTrack(app *model.AppState) http.Handler {
return
}
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(id))
@ -107,11 +107,13 @@ func CreateTrack(app *model.AppState) http.Handler {
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path == "/" {
if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -130,6 +132,8 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return
}
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
@ -142,16 +146,20 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path == "/" {
if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
})
}

View file

@ -1,53 +1,56 @@
package api
import (
"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
}
}
return filename, nil
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
return filename, nil
}

View file

@ -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 czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/

View file

@ -1,11 +1,10 @@
package controller
import (
"arimelody-web/model"
"net/http"
"strings"
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
@ -77,19 +76,6 @@ func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, erro
return &account, nil
}
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) string {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(tokenStr) > 0 {
return tokenStr
}
cookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil {
return ""
}
return cookie.Value
}
func CreateAccount(db *sqlx.DB, account *model.Account) error {
err := db.Get(
&account.ID,
@ -124,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
}

View file

@ -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
}

View file

@ -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 {
@ -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,
@ -76,5 +77,9 @@ func handleConfigOverrides(config *model.Config) error {
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
return nil
}

View file

@ -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")

23
controller/ip.go Normal file
View file

@ -0,0 +1,23 @@
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(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 {
// discard extra IPs; cloudflare tends to append their nodes
forwardedFor = strings.Split(forwardedFor, ", ")[0]
return forwardedFor
}
}
return addr
}

View file

@ -1,14 +1,14 @@
package controller
import (
"fmt"
"os"
"time"
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 2
const DB_VERSION int = 4
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@ -41,6 +41,14 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "001-pre-versioning")
oldDBVersion = 2
case 2:
ApplyMigration(db, "002-audit-logs")
oldDBVersion = 3
case 3:
ApplyMigration(db, "003-fail-lock")
oldDBVersion = 4
}
}
@ -50,7 +58,7 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
func ApplyMigration(db *sqlx.DB, scriptFile string) {
fmt.Printf("Applying schema migration %s...\n", scriptFile)
bytes, err := os.ReadFile("schema_migration/" + scriptFile + ".sql")
bytes, err := os.ReadFile("schema-migration/" + scriptFile + ".sql")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
os.Exit(1)

53
controller/qr.go Normal file
View file

@ -0,0 +1,53 @@
package controller
import (
"encoding/base64"
"image"
"image/color"
"github.com/skip2/go-qrcode"
)
func GenerateQRCode(data string) (string, error) {
imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256)
if err != nil {
return "", err
}
base64Img := base64.StdEncoding.EncodeToString(imgBytes)
return base64Img, nil
}
// vvv DEPRECATED vvv
const margin = 4
type QRCodeECCLevel int64
const (
LOW QRCodeECCLevel = iota
MEDIUM
QUARTILE
HIGH
)
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 7 {
for xi := range 7 {
if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
img.Set(x + xi, y + yi, color.Black)
} else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
img.Set(x + xi, y + yi, color.Black)
}
}
}
}
func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 5 {
for xi := range 5 {
if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
img.Set(x + xi, y + yi, color.Black)
}
}
}
img.Set(x + 2, y + 2, color.Black)
}

View file

@ -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) {

View file

@ -1,16 +1,57 @@
package controller
import (
"database/sql"
"time"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"time"
"arimelody-web/model"
"arimelody-web/log"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
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))
}
var session *model.Session
if sessionCookie != nil {
// fetch existing session
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 {
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
}
}
}
return session, nil
}
func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
tokenString := GenerateAlnumString(TOKEN_LEN)
@ -49,6 +90,17 @@ func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
// return err
// }
func SetSessionAttemptAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
var err error
session.AttemptAccount = account
if account == nil {
_, err = db.Exec("UPDATE session SET attempt_account=NULL WHERE token=$1", session.Token)
} else {
_, err = db.Exec("UPDATE session SET attempt_account=$2 WHERE token=$1", session.Token, account.ID)
}
return err
}
func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
var err error
session.Account = account
@ -89,7 +141,8 @@ func SetSessionError(db *sqlx.DB, session *model.Session, message string) error
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
type dbSession struct {
model.Session
AccountID sql.NullString `db:"account"`
AttemptAccountID sql.NullString `db:"attempt_account"`
AccountID sql.NullString `db:"account"`
}
session := dbSession{}
@ -109,6 +162,13 @@ func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
}
}
if session.AttemptAccountID.Valid {
session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String)
if err != nil {
return nil, err
}
}
return &session.Session, err
}
@ -128,3 +188,7 @@ func DeleteSession(db *sqlx.DB, token string) error {
return err
}
func DeleteExpiredSessions(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM session WHERE expires_at<current_timestamp")
return err
}

View file

@ -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
@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string {
query := url.Query()
query.Set("secret", secret)
query.Set("issuer", "arimelody.me")
query.Set("algorithm", "SHA1")
query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
// query.Set("algorithm", "SHA1")
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
url.RawQuery = query.Encode()
return url.String()
@ -78,7 +78,7 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
err := db.Select(
&totps,
"SELECT * FROM totp " +
"WHERE account=$1 " +
"WHERE account=$1 AND confirmed=true " +
"ORDER BY created_at ASC",
accountID,
)
@ -116,8 +116,9 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
err := db.Get(
&totp,
"SELECT * FROM totp " +
"WHERE account=$1",
"WHERE account=$1 AND name=$2",
accountID,
name,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
@ -129,6 +130,15 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
return &totp, nil
}
func ConfirmTOTP(db *sqlx.DB, accountID string, name string) error {
_, err := db.Exec(
"UPDATE totp SET confirmed=true WHERE account=$1 AND name=$2",
accountID,
name,
)
return err
}
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
_, err := db.Exec(
"INSERT INTO totp (account, name, secret) " +
@ -148,3 +158,8 @@ func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
)
return err
}
func DeleteUnconfirmedTOTPs(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM totp WHERE confirmed=false")
return err
}

View file

@ -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
}

94
controller/twitch.go Normal file
View file

@ -0,0 +1,94 @@
package controller
import (
"arimelody-web/model"
"bytes"
"encoding/json"
"net/http"
"net/url"
"time"
)
const TWITCH_API_BASE = "https://api.twitch.tv/helix/"
func TwitchSetup(app *model.AppState) error {
app.Twitch = &model.TwitchState{}
err := RefreshTwitchToken(app)
return err
}
func RefreshTwitchToken(app *model.AppState) error {
if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) {
return nil
}
requestUrl, _ := url.Parse("https://id.twitch.tv/oauth2/token")
req, _ := http.NewRequest(http.MethodPost, requestUrl.String(), bytes.NewBuffer([]byte(url.Values{
"client_id": []string{ app.Config.Twitch.ClientID },
"client_secret": []string{ app.Config.Twitch.Secret },
"grant_type": []string{ "client_credentials" },
}.Encode())))
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
type TwitchOAuthToken struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
oauthResponse := TwitchOAuthToken{}
err = json.NewDecoder(res.Body).Decode(&oauthResponse)
if err != nil {
return err
}
app.Twitch.Token = &model.TwitchOAuthToken{
AccessToken: oauthResponse.AccessToken,
ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(),
TokenType: oauthResponse.TokenType,
}
return nil
}
var lastStreamState *model.TwitchStreamInfo
var lastStreamStateAt time.Time
func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) {
if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) {
return lastStreamState, nil
}
requestUrl, _ := url.Parse(TWITCH_API_BASE + "streams")
requestUrl.RawQuery = url.Values{
"user_login": []string{ broadcaster },
}.Encode()
req, _ := http.NewRequest(http.MethodGet, requestUrl.String(), nil)
req.Header.Set("Client-Id", app.Config.Twitch.ClientID)
req.Header.Set("Authorization", "Bearer " + app.Twitch.Token.AccessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
type StreamsResponse struct {
Data []model.TwitchStreamInfo `json:"data"`
}
streamInfo := StreamsResponse{}
err = json.NewDecoder(res.Body).Decode(&streamInfo)
if err != nil {
return nil, err
}
if len(streamInfo.Data) == 0 {
return nil, nil
}
lastStreamState = &streamInfo.Data[0]
lastStreamStateAt = time.Now().UTC()
return lastStreamState, nil
}

201
cursor/cursor.go Normal file
View file

@ -0,0 +1,201 @@
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 float32
Y float32
Click bool
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 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,
[]*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 "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.ParseFloat(args[1], 32)
y, err := strconv.ParseFloat(args[2], 32)
if err != nil { return }
client.X = float32(x)
client.Y = float32(y)
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("pos:%d:%f:%f", 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)
}

View file

@ -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"

7
go.mod
View file

@ -8,4 +8,9 @@ require (
)
require golang.org/x/crypto v0.27.0 // indirect
require github.com/pelletier/go-toml/v2 v2.2.3 // 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
)

8
go.sum
View file

@ -2,13 +2,17 @@ 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=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=

143
log/log.go Normal file
View file

@ -0,0 +1,143 @@
package log
import (
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
)
type (
Logger struct {
DB *sqlx.DB
}
Log struct {
ID string `json:"id" db:"id"`
Level LogLevel `json:"level" db:"level"`
Type string `json:"type" db:"type"`
Content string `json:"content" db:"content"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
)
const (
TYPE_ACCOUNT string = "account"
TYPE_MUSIC string = "music"
TYPE_ARTIST string = "artist"
TYPE_BLOG string = "blog"
TYPE_ARTWORK string = "artwork"
TYPE_FILES string = "files"
TYPE_MISC string = "misc"
TYPE_CURSOR string = "cursor"
)
type LogLevel int
const (
LEVEL_INFO LogLevel = 0
LEVEL_WARN LogLevel = 1
)
const DEFAULT_LOG_PAGE_LENGTH = 25
func (self *Logger) Info(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
fmt.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString)
err := createLog(self.DB, LEVEL_INFO, logType, logString)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
}
}
func (self *Logger) Warn(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString)
err := createLog(self.DB, LEVEL_WARN, logType, logString)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
}
}
func (self *Logger) Fetch(id string) (*Log, error) {
log := Log{}
err := self.DB.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
return &log, err
}
func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) {
logs := []*Log{}
params := []any{ limit, offset }
conditions := ""
if len(content) > 0 {
content = "%" + content + "%"
conditions += " WHERE content LIKE $3"
params = append(params, content)
}
if len(levelFilters) > 0 {
if len(conditions) > 0 {
conditions += " AND level IN ("
} else {
conditions += " WHERE level IN ("
}
for i := range levelFilters {
conditions += fmt.Sprintf("$%d", len(params) + 1)
if i < len(levelFilters) - 1 {
conditions += ","
}
params = append(params, levelFilters[i])
}
conditions += ")"
}
if len(typeFilters) > 0 {
if len(conditions) > 0 {
conditions += " AND type IN ("
} else {
conditions += " WHERE type IN ("
}
for i := range typeFilters {
conditions += fmt.Sprintf("$%d", len(params) + 1)
if i < len(typeFilters) - 1 {
conditions += ","
}
params = append(params, typeFilters[i])
}
conditions += ")"
}
query := fmt.Sprintf(
"SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2",
conditions,
)
/*
fmt.Printf("%s (", query)
for i, param := range params {
fmt.Print(param)
if i < len(params) - 1 {
fmt.Print(", ")
}
}
fmt.Print(")\n")
*/
err := self.DB.Select(&logs, query, params...)
if err != nil {
return nil, err
}
return logs, nil
}
func createLog(db *sqlx.DB, logLevel LogLevel, logType string, content string) error {
_, err := db.Exec(
"INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
logLevel,
logType,
content,
)
return err
}

236
main.go
View file

@ -1,29 +1,32 @@
package main
import (
"errors"
"fmt"
"log"
"math"
"math/rand"
"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/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/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
@ -36,6 +39,7 @@ func main() {
app := model.AppState{
Config: controller.GetConfig(),
Twitch: nil,
}
// initialise database connection
@ -77,6 +81,8 @@ func main() {
app.DB.SetMaxIdleConns(10)
defer app.DB.Close()
app.Log = log.Logger{ DB: app.DB }
// handle command arguments
if len(os.Args) > 1 {
arg := os.Args[1]
@ -89,7 +95,6 @@ func main() {
}
username := os.Args[2]
totpName := os.Args[3]
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
@ -102,6 +107,7 @@ func main() {
os.Exit(1)
}
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP {
AccountID: account.ID,
Name: totpName,
@ -118,6 +124,7 @@ func main() {
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
fmt.Printf("%s\n", url)
return
@ -147,6 +154,7 @@ func main() {
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return
@ -216,6 +224,16 @@ func main() {
fmt.Printf("%s\n", code)
return
case "cleanTOTP":
err := controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
return
case "createInvite":
fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
@ -224,6 +242,7 @@ func main() {
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
fmt.Printf(
"Here you go! This code expires in %d hours: %s\n",
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
@ -239,6 +258,7 @@ func main() {
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
fmt.Printf("Invites deleted successfully.\n")
return
@ -256,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
@ -291,11 +313,12 @@ func main() {
account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, account)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username)
return
case "deleteAccount":
@ -323,16 +346,95 @@ 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)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
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)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
os.Exit(1)
}
for _, item := range(logs) {
levelStr := ""
switch item.Level {
case log.LEVEL_INFO:
levelStr = "INFO"
case log.LEVEL_WARN:
levelStr = "WARN"
default:
levelStr = fmt.Sprintf("? (%d)", item.Level)
}
fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
}
return
}
// command help
@ -342,11 +444,15 @@ func main() {
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
"cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\n" +
"\n" +
"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 <username>:\n\tDeletes an account with a given `username`.\n",
"deleteAccount <username>:\n\tDeletes the account under `username`.\n",
"lockAccount <username>:\n\tLocks the account under `username`.\n",
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
"logs:\n\tShows system logs.\n",
)
return
}
@ -354,6 +460,13 @@ func main() {
// handle DB migrations
controller.CheckDBVersionAndMigrate(app.DB)
if app.Config.Twitch != nil {
err = controller.TwitchSetup(&app)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
}
}
// initial invite code
accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
@ -374,6 +487,13 @@ func main() {
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
}
// delete expired sessions
err = controller.DeleteExpiredSessions(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
os.Exit(1)
}
// delete expired invites
err = controller.DeleteExpiredInvites(app.DB)
if err != nil {
@ -381,10 +501,19 @@ func main() {
os.Exit(1)
}
// clean up unconfirmed TOTP methods
err = controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
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)
log.Fatal(
stdLog.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
HTTPLog(DefaultHeaders(mux)),
))
@ -396,48 +525,13 @@ func createServeMux(app *model.AppState) *http.ServeMux {
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
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("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.Pages["index"].Execute(w, nil)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
staticHandler("public").ServeHTTP(w, r)
}))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", view.IndexHandler(app))
return mux
}
func staticHandler(directory string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
})
}
var PoweredByStrings = []string{
"nerd rage",
"estrogen",
@ -489,6 +583,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)

View file

@ -1,20 +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"`
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"`
}

View file

@ -1,6 +1,10 @@
package model
import "github.com/jmoiron/sqlx"
import (
"github.com/jmoiron/sqlx"
"arimelody-web/log"
)
type (
DBConfig struct {
@ -17,17 +21,27 @@ type (
Secret string `toml:"secret"`
}
TwitchConfig struct {
Broadcaster string `toml:"broadcaster"`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
Config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
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"`
Discord *DiscordConfig `toml:"discord"`
Twitch *TwitchConfig `toml:"twitch"`
}
AppState struct {
DB *sqlx.DB
Config Config
Log log.Logger
Twitch *TwitchState
}
)

View file

@ -1,21 +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) GetWebsite() string {
return artist.Website
}
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
}

23
model/artist_test.go Normal file
View file

@ -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)
}
}

View file

@ -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 strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
rgx := regexp.MustCompile(`[^a-z0-9\-]`)
return rgx.ReplaceAllString(strings.ToLower(link.Name), "")
}

23
model/link_test.go Normal file
View file

@ -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)
}
}

View file

@ -1,9 +1,9 @@
package model
import (
"html/template"
"strings"
"time"
"html/template"
"strings"
"time"
)
type (
@ -24,6 +24,7 @@ type (
Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"`
Links []*Link `json:"links"`
CreatedAt time.Time `json:"-" db:"created_at"`
}
)
@ -49,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"
@ -76,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[:], ", ")
}
}

157
model/release_test.go Normal file
View file

@ -0,0 +1,157 @@
package model
import (
"testing"
"time"
)
func Test_Release_DescriptionHTML(t *testing.T) {
release := Release{
Description: "this is\na test\n<strong>description!</strong>",
}
// descriptions are set by privileged users,
// so we'll allow HTML injection here
want := "this is<br>a test<br><strong>description!</strong>"
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)
}
}
}

View file

@ -1,17 +1,18 @@
package model
import (
"database/sql"
"time"
"database/sql"
"time"
)
type Session struct {
Token string `json:"token" db:"token"`
Token string `json:"-" db:"token"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
ExpiresAt time.Time `json:"-" db:"expires_at"`
Account *Account `json:"-" db:"account"`
Account *Account `json:"-" db:"-"`
AttemptAccount *Account `json:"-" db:"-"`
Message sql.NullString `json:"-" db:"message"`
Error sql.NullString `json:"-" db:"error"`
}

View file

@ -1,7 +1,7 @@
package model
import (
"time"
"time"
)
type TOTP struct {
@ -9,4 +9,5 @@ type TOTP struct {
AccountID string `json:"accountID" db:"account"`
Secret string `json:"-" db:"secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Confirmed bool `json:"-" db:"confirmed"`
}

View file

@ -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 {

43
model/track_test.go Normal file
View file

@ -0,0 +1,43 @@
package model
import (
"testing"
)
func Test_Track_DescriptionHTML(t *testing.T) {
track := Track{
Description: "this is\na test\n<strong>description!</strong>",
}
// descriptions are set by privileged users,
// so we'll allow HTML injection here
want := "this is<br>a test<br><strong>description!</strong>"
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\n<strong>lyrics!</strong>",
}
// lyrics are set by privileged users,
// so we'll allow HTML injection here
want := "these are<br>test<br><strong>lyrics!</strong>"
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)
}
}

43
model/twitch.go Normal file
View file

@ -0,0 +1,43 @@
package model
import (
"fmt"
"strings"
"time"
)
type (
TwitchOAuthToken struct {
AccessToken string
ExpiresAt time.Time
TokenType string
}
TwitchState struct {
Token *TwitchOAuthToken
}
TwitchStreamInfo struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserLogin string `json:"user_login"`
UserName string `json:"user_name"`
GameID string `json:"game_id"`
GameName string `json:"game_name"`
Type string `json:"type"`
Title string `json:"title"`
ViewerCount int `json:"viewer_count"`
StartedAt string `json:"started_at"`
Language string `json:"language"`
ThumbnailURL string `json:"thumbnail_url"`
TagIDs []string `json:"tag_ids"`
Tags []string `json:"tags"`
IsMature bool `json:"is_mature"`
}
)
func (info *TwitchStreamInfo) Thumbnail(width int, height int) string {
res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1)
res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1)
return res
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M256,512C397.384,512 512,397.385 512,256C512,114.616 397.384,0 256,0C114.615,0 0,114.616 0,256C0,397.385 114.615,512 256,512Z" style="fill:rgb(35,159,194);"/>
<path d="M324.857,238.405C306.507,238.405 297.131,252.847 297.131,274.605C297.131,295.171 307.269,310.609 324.857,310.609C344.746,310.609 352.202,292.407 352.202,274.605C352.188,256.015 342.817,238.405 324.851,238.405L324.857,238.405ZM276.1,184.409L297.896,184.409L297.896,236.624L298.282,236.624C304.209,226.737 316.637,220.603 327.728,220.603C358.89,220.603 374.001,245.137 374.001,275.007C374.001,302.492 360.618,328.405 331.358,328.405C317.974,328.405 303.633,325.051 297.13,311.596L296.752,311.596L296.752,325.647L276.098,325.647L276.098,184.412L276.1,184.409Z" style="fill:white;"/>
<path d="M454.389,257.598C452.667,245.136 443.874,238.406 431.827,238.406C420.54,238.406 404.674,244.541 404.674,275.598C404.674,292.61 411.938,310.613 430.87,310.613C443.488,310.613 452.281,301.899 454.389,287.262L476.185,287.262C472.169,313.768 456.302,328.405 430.87,328.405C399.893,328.405 382.876,305.663 382.876,275.598C382.876,244.742 399.129,220.609 431.635,220.609C454.579,220.609 474.089,232.476 476.185,257.6L454.425,257.6L454.389,257.598Z" style="fill:white;"/>
<path d="M199.895,325.339L36.407,325.339L112.753,184.409L276.242,184.409L199.895,325.339Z" style="fill:white;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 568 501" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-228,-281.442)">
<path d="M351.121,315.106C416.241,363.994 486.281,463.123 512,516.315C537.719,463.123 607.759,363.994 672.879,315.106C719.866,279.83 796,252.536 796,339.388C796,356.734 786.055,485.101 780.222,505.943C759.947,578.396 686.067,596.876 620.347,585.691C735.222,605.242 764.444,670.002 701.333,734.762C581.473,857.754 529.061,703.903 515.631,664.481C513.169,657.254 512.017,653.873 512,656.748C511.983,653.873 510.831,657.254 508.369,664.481C494.939,703.903 442.527,857.754 322.667,734.762C259.556,670.002 288.778,605.242 403.653,585.691C337.933,596.876 264.053,578.396 243.778,505.943C237.945,485.101 228,356.734 228,339.388C228,252.536 304.134,279.83 351.121,315.106Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(3.06233e-14,500.117,-500.117,3.06233e-14,512,281.442)"><stop offset="0" style="stop-color:rgb(10,122,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(89,185,255);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
viewBox="0 0 4.2333332 4.2333335"
version="1.1"
id="svg1468"
sodipodi:docname="codeberg-logo_icon_blue.svg"
inkscape:version="1.2-alpha1 (b6a15bb, 2022-02-23)"
inkscape:export-filename="/home/mray/Projects/Codeberg/logo/icon/png/codeberg-logo_icon_blue.png"
inkscape:export-xdpi="384"
inkscape:export-ydpi="384"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title16">Codeberg logo</title>
<defs
id="defs1462">
<linearGradient
xlink:href="#linearGradient6924"
id="linearGradient6918"
x1="42519.285"
y1="-7078.7891"
x2="42575.336"
y2="-6966.9307"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient6924">
<stop
style="stop-color:#2185d0;stop-opacity:0"
offset="0"
id="stop6920" />
<stop
id="stop6926"
offset="0.49517274"
style="stop-color:#2185d0;stop-opacity:0.48923996" />
<stop
style="stop-color:#2185d0;stop-opacity:0.63279623"
offset="1"
id="stop6922" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient6924-6"
id="linearGradient6918-3"
x1="42519.285"
y1="-7078.7891"
x2="42575.336"
y2="-6966.9307"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient6924-6">
<stop
style="stop-color:#2185d0;stop-opacity:0;"
offset="0"
id="stop6920-7" />
<stop
id="stop6926-5"
offset="0.49517274"
style="stop-color:#2185d0;stop-opacity:0.30000001;" />
<stop
style="stop-color:#2185d0;stop-opacity:0.30000001;"
offset="1"
id="stop6922-3" />
</linearGradient>
</defs>
<sodipodi:namedview
showborder="false"
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="12.948893"
inkscape:cy="12.661631"
inkscape:document-units="px"
inkscape:current-layer="svg1468"
inkscape:document-rotation="0"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
inkscape:snap-global="false"
inkscape:snap-page="true"
showguides="false"
inkscape:window-width="1531"
inkscape:window-height="873"
inkscape:window-x="69"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid2067" />
</sodipodi:namedview>
<metadata
id="metadata1465">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>Codeberg logo</dc:title>
<cc:license
rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" />
<dc:creator>
<cc:Agent>
<dc:title>Robert Martinez</dc:title>
</cc:Agent>
</dc:creator>
<dc:rights>
<cc:Agent>
<dc:title>Codeberg and the Codeberg Logo are trademarks of Codeberg e.V.</dc:title>
</cc:Agent>
</dc:rights>
<dc:date>2020-04-09</dc:date>
<dc:publisher>
<cc:Agent>
<dc:title>Codeberg e.V.</dc:title>
</cc:Agent>
</dc:publisher>
<dc:source>codeberg.org</dc:source>
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/publicdomain/zero/1.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
</cc:License>
</rdf:RDF>
</metadata>
<g
id="g370484"
inkscape:label="logo"
transform="matrix(0.06551432,0,0,0.06551432,-2.232417,-1.431776)">
<path
id="path6733-5"
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:url(#linearGradient6918-3);fill-opacity:1;stroke:none;stroke-width:3.67846;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:2;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill;stop-color:#000000;stop-opacity:1"
d="m 42519.285,-7078.7891 a 0.76086879,0.56791688 0 0 0 -0.738,0.6739 l 33.586,125.8886 a 87.182358,87.182358 0 0 0 39.381,-33.7636 l -71.565,-92.5196 a 0.76086879,0.56791688 0 0 0 -0.664,-0.2793 z"
transform="matrix(0.37058478,0,0,0.37058478,-15690.065,2662.0533)"
inkscape:label="berg" />
<path
id="path360787"
style="opacity:1;fill:#2185d0;fill-opacity:1;stroke-width:17.0055;paint-order:markers fill stroke;stop-color:#000000"
d="m 11249.461,-1883.6961 c -12.74,0 -23.067,10.3275 -23.067,23.0671 0,4.3335 1.22,8.5795 3.522,12.2514 l 19.232,-24.8636 c 0.138,-0.1796 0.486,-0.1796 0.624,0 l 19.233,24.8646 c 2.302,-3.6721 3.523,-7.9185 3.523,-12.2524 0,-12.7396 -10.327,-23.0671 -23.067,-23.0671 z"
sodipodi:nodetypes="sccccccs"
inkscape:label="sky"
transform="matrix(1.4006354,0,0,1.4006354,-15690.065,2662.0533)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Discord-Logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.644 96"><defs><style>.cls-1{fill:#5865f2;}</style></defs><path id="Discord-Symbol-Blurple" class="cls-1" d="M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#9146FF;}
</style>
<title>Asset 2</title>
<g>
<polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/>
<g>
<g id="Layer_1-2">
<path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600
V1300z"/>
<rect x="1700" y="550" class="st1" width="200" height="600"/>
<rect x="1150" y="550" class="st1" width="200" height="600"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 507 355" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(4.16667,0,0,4.16667,495.608,299.004)">
<path d="M0,-58.482C-1.397,-63.709 -5.514,-67.825 -10.741,-69.222C-20.215,-71.761 -58.204,-71.761 -58.204,-71.761C-58.204,-71.761 -96.193,-71.761 -105.667,-69.222C-110.894,-67.825 -115.011,-63.709 -116.408,-58.482C-118.946,-49.008 -118.946,-29.241 -118.946,-29.241C-118.946,-29.241 -118.946,-9.474 -116.408,-0.001C-115.011,5.226 -110.894,9.343 -105.667,10.74C-96.193,13.279 -58.204,13.279 -58.204,13.279C-58.204,13.279 -20.215,13.279 -10.741,10.74C-5.514,9.343 -1.397,5.226 0,-0.001C2.539,-9.474 2.539,-29.241 2.539,-29.241C2.539,-29.241 2.539,-49.008 0,-58.482" style="fill:rgb(255,0,0);fill-rule:nonzero;"/>
</g>
<g transform="matrix(4.16667,0,0,4.16667,202.472,101.237)">
<path d="M0,36.446L31.562,18.223L0,0L0,36.446Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -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-----

View file

@ -1,33 +1,106 @@
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<string, Array<Function>> */
#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;
this.save();
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);
})
}
get cursor() { return this._cursor }
set cursor(/** @type boolean */ value) {
this._cursor = value;
this.save();
this.#listeners.get('cursor').forEach(callback => {
callback(this._cursor);
})
}
get cursorFunMode() { return this._cursorFunMode }
set cursorFunMode(/** @type boolean */ value) {
this._cursorFunMode = value;
this.save();
this.#listeners.get('cursorFunMode').forEach(callback => {
callback(this._cursorFunMode);
})
}
}
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;

403
public/script/cursor.js Normal file
View file

@ -0,0 +1,403 @@
import config from './config.js';
const CURSOR_LERP_RATE = 1/100;
const CURSOR_FUNCHAR_RATE = 20;
const CURSOR_CHAR_MAX_LIFE = 5000;
const CURSOR_MAX_CHARS = 64;
/** @type HTMLCanvasElement */
let canvas;
/** @type CanvasRenderingContext2D */
let ctx;
/** @type Cursor */
let myCursor;
/** @type Map<number, Cursor> */
let cursors = new Map();
/** @type WebSocket */
let ws;
let running = false;
let lastUpdate = 0;
let cursorBoxHeight = 0;
let cursorBoxRadius = 0;
let cursorIDFontSize = 0;
let cursorCharFontSize = 0;
class Cursor {
#funCharCooldown = CURSOR_FUNCHAR_RATE;
/**
* @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 = '';
/** @type Array<FunChar> */
this.funChars = new Array();
this.colour = randomColour();
this.click = false;
}
/**
* @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;
if (this.#funCharCooldown > 0)
this.#funCharCooldown -= deltaTime;
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) {
this.funChars.shift();
}
const yOffset = -10 / innerHeight;
const accelMultiplier = 0.002;
this.funChars.push(new FunChar(
this.x, this.y + yOffset,
(this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier,
this.msg));
}
}
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);
}
const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches;
if (lightTheme)
ctx.filter = 'saturate(5) brightness(0.8)';
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 = '';
}
}
class FunChar {
/**
* @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() * .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;
}
/**
* @param {number} deltaTime
*/
update(deltaTime) {
this.life += deltaTime;
this.x += this.xa * deltaTime;
this.y += this.ya * deltaTime;
this.r += this.ra * deltaTime;
this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10);
const x = this.x * innerWidth - scrollX;
const y = this.y * innerHeight - scrollY;
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();
}
}
/**
* @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
*/
let mouseMoveLock = false;
const mouseMoveCooldown = 1000/30;
function handleMouseMove(event) {
if (!myCursor) return;
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;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send('click:1');
}
function handleMouseUp() {
myCursor.click = false;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send('click:0');
}
/**
* @param {KeyboardEvent} event
*/
function handleKeyPress(event) {
if (event.key.length > 1) return;
if (event.metaKey || event.ctrlKey) return;
if (myCursor.msg === event.key) return;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send(`char:${event.key}`);
myCursor.msg = event.key;
}
function handleKeyUp() {
if (ws && ws.readyState == WebSocket.OPEN)
ws.send(`nochar`);
myCursor.msg = '';
}
/**
* @param {number} timestamp
*/
function update(timestamp) {
if (!running) return;
const deltaTime = timestamp - lastUpdate;
lastUpdate = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
cursors.forEach(cursor => {
cursor.update(deltaTime);
});
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;
canvas = document.createElement('canvas');
canvas.id = 'cursors';
handleWindowResize();
document.body.appendChild(canvas);
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(update);
ws = new WebSocket('/cursor-ws');
ws.addEventListener('open', () => {
console.log('Cursor connected to server successfully.');
ws.send(`loc:${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.id = id;
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.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 'click': {
if (!cursor || cursor === myCursor) break;
cursor.click = args[2] == '1';
break;
}
case 'pos': {
if (!cursor || cursor === myCursor) break;
cursor.x = Number(args[2]);
cursor.y = Number(args[3]);
break;
}
default: {
console.warn('Cursor: Unknown command received from server:', args[0]);
break;
}
}
});
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);
ctx.clearRect(0, 0, canvas.width, canvas.height);
cursors.clear();
myCursor = null;
console.log(`Cursor no longer tracking.`);
running = false;
}
if (config.cursor === true) {
cursorSetup();
}
config.addListener('cursor', enabled => {
if (enabled === true)
cursorSetup();
else
cursorDestroy();
});

View file

@ -1,3 +1,5 @@
import { hijackClickEvent } from "./main.js";
const hexPrimary = document.getElementById("hex-primary");
const hexSecondary = document.getElementById("hex-secondary");
const hexTertiary = document.getElementById("hex-tertiary");
@ -14,3 +16,8 @@ updateHexColours();
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
updateHexColours();
});
document.querySelectorAll("ul#projects li.project-item").forEach(projectItem => {
const link = projectItem.querySelector('a');
hijackClickEvent(projectItem, link);
});

View file

@ -1,5 +1,6 @@
import "./header.js";
import "./config.js";
import "./cursor.js";
function type_out(e) {
const text = e.innerText;
@ -44,12 +45,28 @@ function fill_list(list) {
});
}
export function hijackClickEvent(container, link) {
container.addEventListener('click', event => {
if (event.target.tagName.toLowerCase() === 'a') return;
event.preventDefault();
link.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
button: event.button,
}));
});
}
document.addEventListener("DOMContentLoaded", () => {
[...document.querySelectorAll(".typeout")]
.filter((e) => e.innerText != "")
.forEach((e) => {
type_out(e);
console.log(e);
});
[...document.querySelectorAll("ol, ul")]
.filter((e) => e.innerText != "")

View file

@ -1,12 +1,6 @@
import "./main.js";
import { hijackClickEvent } from "./main.js";
document.querySelectorAll("div.music").forEach(container => {
const link = container.querySelector(".music-title a").href
container.addEventListener("click", event => {
if (event.target.href) return;
event.preventDefault();
location = link;
});
const link = container.querySelector(".music-title a")
hijackClickEvent(container, link);
});

View file

@ -1,66 +1,74 @@
/**
* 🏳🌈🏳💖 pride flag 💖🏳🏳🌈
* made with by ari melody, 2023
*
* web: https://arimelody.me
* source: https://github.com/mellodoot/prideflag
*/
//
// pride flag - copyright (c) 2024 ari melody
//
// this code is provided AS-IS, WITHOUT ANY WARRANTY, to be
// freely redistributed and/or modified as you please, however
// retaining this license in any redistribution.
//
// please use this flag to link to an LGBTQI+-supporting page
// of your choosing!
//
// web: https://arimelody.me
// source: https://git.arimelody.me/ari/prideflag
//
const pride_url = "https://git.arimelody.me/ari/prideflag";
const pride_flag_svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>`;
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>`;
const pride_flag_css =
`#pride-flag svg {
position: fixed;
top: 0;
right: 0;
width: 120px;
transform-origin: 100% 0%;
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
z-index: 8008135;
pointer-events: none;
}
#pride-flag svg:hover {
transform: scale(110%);
}
#pride-flag svg:active {
transform: scale(110%);
}
#pride-flag svg * {
pointer-events: all;
}`;
`#prideflag {
position: fixed;
top: 0;
right: 0;
width: 120px;
transform-origin: 100% 0%;
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
z-index: 8008135;
pointer-events: none;
}
#prideflag:hover {
transform: scale(110%);
}
#prideflag:active {
transform: scale(110%);
}
#prideflag * {
pointer-events: all;
}`;
function create_pride_flag() {
const container = document.createElement("a");
container.id = "pride-flag";
container.href = "https://github.com/mellodoot/prideflag";
container.target = "_blank";
container.innerHTML = pride_flag_svg;
return container;
const flag = document.createElement("a");
flag.id = "prideflag";
flag.href = pride_url;
flag.target = "_blank";
flag.innerHTML = pride_flag_svg;
return flag;
}
function load_pride_flag_style() {
const pride_stylesheet = document.createElement('style');
pride_stylesheet.textContent = pride_flag_css;
document.head.appendChild(pride_stylesheet);
const pride_stylesheet = document.createElement('style');
pride_stylesheet.textContent = pride_flag_css;
document.head.appendChild(pride_stylesheet);
}
load_pride_flag_style();
pride_flag = create_pride_flag();
document.querySelector("main").appendChild(pride_flag);
flag = create_pride_flag();
document.body.appendChild(flag);

View file

@ -6,6 +6,7 @@
--secondary: #f8e05b;
--tertiary: #f788fe;
--links: #5eb2ff;
--live: #fd3737;
}
@media (prefers-color-scheme: light) {

9
public/style/cursor.css Normal file
View file

@ -0,0 +1,9 @@
canvas#cursors {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 100;
}

View file

@ -25,7 +25,6 @@ nav {
flex-grow: 1;
display: flex;
gap: .5em;
cursor: pointer;
}
img#header-icon {
@ -36,19 +35,19 @@ img#header-icon {
}
#header-text {
width: 11em;
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
}
#header-text h1 {
width: fit-content;
margin: 0;
font-size: 1em;
}
#header-text h2 {
width: fit-content;
height: 1.2em;
line-height: 1.2em;
margin: 0;
@ -154,7 +153,7 @@ header ul li a:hover {
flex-direction: column;
gap: 1rem;
border-bottom: 1px solid #888;
background: #080808;
background: var(--background);
display: none;
}

View file

@ -96,30 +96,36 @@ hr {
overflow: visible;
}
ul.links {
ul.platform-links {
padding-left: 1em;
display: flex;
gap: 1em .5em;
gap: .5em;
flex-wrap: wrap;
}
ul.links li {
ul.platform-links li {
list-style: none;
}
ul.links li a {
ul.platform-links li a {
padding: .4em .5em;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: .5em;
border: 1px solid var(--links);
color: var(--links);
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-property: color, border-color, background-color, box-shadow;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
}
ul.links li a:hover {
ul.platform-links li a:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
@ -127,6 +133,75 @@ ul.links li a:hover {
box-shadow: 0 0 1em var(--links);
}
ul.platform-links li a img {
height: 1em;
width: 1em;
}
ul#projects {
padding: 0;
list-style: none;
}
li.project-item {
padding: .5em;
border: 1px solid var(--links);
margin: 1em 0;
display: flex;
flex-direction: row;
gap: .5em;
border-radius: 2px;
transition-property: color, border-color, background-color, box-shadow;
transition-duration: .2s;
cursor: pointer;
}
li.project-item a {
transition: color .2s linear;
}
li.project-item:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
box-shadow: 0 0 1em var(--links);
}
li.project-item:hover a {
color: #eee;
}
li.project-item .project-info {
display: flex;
flex-direction: column;
justify-content: center;
}
li.project-item img.project-icon {
width: 2.5em;
height: 2.5em;
object-fit: cover;
border-radius: 2px;
}
li.project-item span.project-icon {
font-size: 2em;
display: block;
width: 45px;
height: 45px;
text-align: center;
/* background: #0004; */
/* border: 1px solid var(--on-background); */
border-radius: 2px;
}
li.project-item a {
text-decoration: none;
}
li.project-item p {
margin: 0;
}
div#web-buttons {
margin: 2rem 0;
}
@ -148,3 +223,112 @@ div#web-buttons {
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
}
#live-banner {
margin: 1em 0 2em 0;
padding: 1em;
border-radius: 4px;
border: 1px solid var(--primary);
box-shadow: 0 0 8px var(--primary);
}
#live-banner p {
margin: 0;
}
.live-highlight {
color: var(--primary);
}
.live-preview {
display: flex;
flex-direction: row;
justify-content: start;
gap: 1em;
}
.live-preview div:first-of-type {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: .3em;
}
.live-thumbnail {
border-radius: 4px;
}
.live-button {
margin: .2em;
padding: .4em .5em;
display: inline-block;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 4px;
transition: color .1s linear, background-color .1s linear, box-shadow .1s linear;
}
.live-button:hover {
color: var(--background);
background-color: var(--primary);
box-shadow: 0 0 8px var(--primary);
text-decoration: none;
}
.live-info {
display: flex;
flex-direction: column;
gap: .3em;
overflow-x: hidden;
}
#live-banner h2 {
margin: 0;
color: var(--on-background);
font-family: 'Inter', sans-serif;
font-weight: 800;
font-style: italic;
}
.live-pinger {
width: .5em;
height: .5em;
margin: .1em .2em;
display: inline-block;
border-radius: 100%;
background-color: var(--primary);
box-shadow: 0 0 4px var(--primary);
animation: live-pinger-pulse 1s infinite alternate ease-in-out;
}
@keyframes live-pinger-pulse {
from {
opacity: .8;
transform: scale(1.0);
}
to {
opacity: 1;
transform: scale(1.1);
}
}
.live-game {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
}
.live-game .live-game-prefix {
opacity: .8;
}
.live-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.live-viewers {
opacity: .5;
}

View file

@ -2,6 +2,8 @@
@import url("/style/header.css");
@import url("/style/footer.css");
@import url("/style/prideflag.css");
@import url("/style/cursor.css");
@import url("/font/inter/inter.css");
@font-face {
font-family: "Monaspace Argon";

View file

@ -613,6 +613,10 @@ footer a:hover {
margin: 0 auto;
}
#tracks h2 {
margin: 0 auto .8em auto;
}
#lyrics p.album-track-subheading {
margin-bottom: 1em;
}

View file

@ -2,6 +2,15 @@
-- Tables
--
-- Audit logs
CREATE TABLE arimelody.auditlog (
id UUID DEFAULT gen_random_uuid(),
level int NOT NULL DEFAULT 0,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
-- Accounts
CREATE TABLE arimelody.account (
id UUID DEFAULT gen_random_uuid(),
@ -9,7 +18,9 @@ CREATE TABLE arimelody.account (
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
created_at TIMESTAMP DEFAULT current_timestamp
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);
@ -23,29 +34,31 @@ ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account
-- Invites
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Session
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
attempt_account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTPs
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
confirmed BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
@ -72,7 +85,8 @@ CREATE TABLE arimelody.musicrelease (
buyname text,
buylink text,
copyright text,
copyrightURL text
copyrightURL text,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
@ -119,6 +133,7 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -23,33 +23,36 @@ ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account
-- Invites
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Session
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
attempt_account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTPs
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
confirmed BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Foreign keys
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -0,0 +1,12 @@
-- Audit logs
CREATE TABLE arimelody.auditlog (
id UUID DEFAULT gen_random_uuid(),
level int NOT NULL DEFAULT 0,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
-- Need moar timestamps
ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp;
ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL;

View file

@ -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;

View file

@ -1,33 +1,28 @@
package templates
import (
"html/template"
"path/filepath"
"html/template"
"path/filepath"
)
var Pages = map[string]*template.Template{
"index": template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "index.html"),
)),
"music": template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music.html"),
)),
"music-gateway": template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music-gateway.html"),
)),
}
var Components = map[string]*template.Template{
}
var IndexTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "index.html"),
))
var MusicTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music.html"),
))
var MusicGatewayTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music-gateway.html"),
))

45
view/index.go Normal file
View file

@ -0,0 +1,45 @@
package view
import (
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/templates"
"fmt"
"net/http"
"os"
)
func IndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
type IndexData struct {
TwitchStatus *model.TwitchStreamInfo
}
var err error
var twitchStatus *model.TwitchStreamInfo = nil
if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 {
twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err)
}
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.IndexTemplate.Execute(w, IndexData{
TwitchStatus: twitchStatus,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render index page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
StaticHandler("public").ServeHTTP(w, r)
})
}

View file

@ -1,12 +1,13 @@
package view
import (
"fmt"
"net/http"
"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
@ -36,7 +37,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
if err != nil {
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -47,7 +48,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
}
}
err = templates.Pages["music"].Execute(w, releases)
err = templates.MusicTemplate.Execute(w, releases)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -59,7 +60,13 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
// only allow authorised users to view hidden releases
privileged := false
if !release.Visible {
session := r.Context().Value("session").(*model.Session)
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)
return
}
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
@ -79,7 +86,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
response.Links = release.Links
}
err := templates.Pages["music-gateway"].Execute(w, response)
err := templates.MusicGatewayTemplate.Execute(w, response)
if err != nil {
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)

31
view/static.go Normal file
View file

@ -0,0 +1,31 @@
package view
import (
"errors"
"net/http"
"os"
"path/filepath"
)
func StaticHandler(directory string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
})
}

View file

@ -6,16 +6,16 @@
{{define "content"}}
<main>
<h1>
# 404 - not found!
</h1>
<h1>
# 404 - not found!
</h1>
<p>
the page you're looking for does not exist.
<br>
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
</p>
<p>
the page you're looking for does not exist.
<br>
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
</p>
<p><small>status: ERR_NOT_FOUND</small></p>
<p><small>status: ERR_NOT_FOUND</small></p>
</main>
{{end}}

View file

@ -1,9 +1,14 @@
{{define "footer"}}
<footer>
<div id="footer">
<small><em>*made with ♥ by ari, 2024*</em></small>
</div>
<div id="footer">
<small>
<em>
*made with <span aria-label="love"></span> by ari, 2025*
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
</em>
</small>
</div>
</footer>
{{end}}

View file

@ -1,44 +1,41 @@
{{define "header"}}
<header>
<nav>
<div id="header-home">
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
<div id="header-text">
<h1>ari melody</h1>
<h2>your local SPACEGIRL 💫</h2>
</div>
</div>
<a id="header-links-toggle">
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
</svg>
</a>
<ul id="header-links">
<li>
<a href="/" preload="mouseover">home</a>
</li>
<li>
<a href="/music" preload="mouseover">music</a>
</li>
<li>
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">blog</span>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">art</span>
</li>
<li id="toggle-crt">
<a href="javascript:void(0)">crt</a>
</li>
</ul>
</nav>
<nav>
<div id="header-home">
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
<div id="header-text">
<h1>ari melody</h1>
<h2>your local SPACEGIRL 💫</h2>
</div>
</div>
<a id="header-links-toggle">
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
</svg>
</a>
<ul id="header-links">
<li>
<a href="/" preload="mouseover">home</a>
</li>
<li>
<a href="/music" preload="mouseover">music</a>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">blog</span>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">art</span>
</li>
<li id="toggle-crt">
<a href="javascript:void(0)">crt</a>
</li>
</ul>
</nav>
</header>
{{end}}

View file

@ -6,8 +6,8 @@
<meta property="og:title" content="ari melody">
<meta property="og:type" content="website">
<meta property="og:url" content="www.arimelody.me">
<meta property="og:image" content="https://www.arimelody.me/img/favicon.png">
<meta property="og:url" content="www.arimelody.space">
<meta property="og:image" content="https://www.arimelody.space/img/favicon.png">
<meta property="og:site_name" content="ari melody">
<meta property="og:description" content="home to your local SPACEGIRL 💫">
@ -17,11 +17,28 @@
<link rel="me" href="https://ice.arimelody.me/@ari">
<link rel="me" href="https://wetdry.world/@ari">
<script type="module" src="/script/index.js" defer> </script>
<script type="module" src="/script/index.js" defer></script>
{{end}}
{{define "content"}}
<main>
{{if .TwitchStatus}}
<div id="live-banner">
<div class="live-preview">
<div>
<img src="{{.TwitchStatus.Thumbnail 144 81}}" alt="livestream thumbnail" class="live-thumbnail">
<a href="https://twitch.tv/{{.TwitchStatus.UserName}}" class="live-button">join in!</a>
</div>
<div class="live-info">
<h2>ari melody <span class="live-highlight">LIVE</span> <i class="live-pinger"></i></h2>
<p class="live-game"><span class="live-game-prefix">streaming:</span> {{.TwitchStatus.GameName}}</p>
<p class="live-title">{{.TwitchStatus.Title}}</p>
<p class="live-viewers">{{.TwitchStatus.ViewerCount}} viewers</p>
</div>
</div>
</div>
{{end}}
<h1 class="typeout">
# hello, world!
</h1>
@ -33,7 +50,7 @@
</p>
<p>
i'm a <a href="/music">musician</a>, <a href="https://github.com/arimelody?tab=repositories">developer</a>,
i'm a <a href="/music">musician</a>, <a href="https://codeberg.org/arimelody?tab=repositories">developer</a>,
<a href="https://twitch.tv/arispacegirl">streamer</a>, <a href="https://youtube.com/@arispacegirl">youtuber</a>,
and probably a bunch of other things i forgot to mention!
</p>
@ -44,7 +61,7 @@
<p>
if you're looking to support me financially, that's so cool of you!!
if you like, you can buy some of my music over on
<a href="https://arimelody.bandcamp.com" target="_blank">bandcamp</a>
<a href="https://arimelody.bandcamp.com">bandcamp</a>
so you can at least get something for your money.
thank you very much either way!! 💕
</p>
@ -84,57 +101,97 @@
<p>
<strong>where to find me 🛰️</strong>
</p>
<ul class="links">
<ul class="platform-links">
<li>
<a href="https://youtube.com/@arispacegirl" target="_blank">youtube</a>
<a href="https://youtube.com/@arispacegirl" title="youtube">
<img src="/img/brand/youtube.svg" alt="youtube" width="32" height="32"/>
youtube
</a>
</li>
<li>
<a href="https://twitch.tv/arispacegirl" target="_blank">twitch</a>
<a href="https://twitch.tv/arispacegirl" title="twitch">
<img src="/img/brand/twitch.svg" alt="twitch" width="32" height="32"/>
twitch
</a>
</li>
<li>
<a href="https://sptfy.com/mellodoot" target="_blank">spotify</a>
<a href="https://arimelody.bandcamp.com" title="bandcamp">
<img src="/img/brand/bandcamp.svg" alt="bandcamp" width="32" height="32"/>
bandcamp
</a>
</li>
<li>
<a href="https://arimelody.bandcamp.com" target="_blank">bandcamp</a>
<a href="https://codeberg.org/arimelody" title="codeberg">
<img src="/img/brand/codeberg.svg" alt="codeberg" width="32" height="32"/>
codeberg
</a>
</li>
<li>
<a href="https://github.com/arimelody" target="_blank">github</a>
<a href="https://bsky.app/profile/arimelody.space" title="bluesky">
<img src="/img/brand/bluesky.svg" alt="bluesky" width="32" height="32"/>
bluesky
</a>
</li>
<li>
<a href="https://arimelody.space/discord" title="discord">
<img src="/img/brand/discord.svg" alt="discord" width="32" height="32"/>
discord
</a>
</li>
</ul>
<p>
<strong>projects i've worked on 🛠️</strong>
</p>
<ul class="links">
<li>
<a href="https://catdance.arimelody.me" target="_blank">
catdance
</a>
<ul id="projects">
<li class="project-item">
<span aria-hidden=true class="project-icon">⛏️</span>
<div class="project-info">
<a href="https://mcq.bliss.town">McStatusFace</a>
<p>minecraft server query utility</p>
</div>
</li>
<li>
<a href="https://git.arimelody.me/ari/prideflag" target="_blank">
pride flag
</a>
<li class="project-item">
<img src="https://catdance.arimelody.me/img/favicon.png" alt="catdance icon" aria-hidden=true class="project-icon" width="64" height="64">
<div class="project-info">
<a href="https://catdance.arimelody.me">catdance</a>
<p>watch the cat dance 🐱</p>
</div>
</li>
<li>
<a href="https://github.com/arimelody/ipaddrgen" target="_blank">
ipaddrgen
</a>
<li class="project-item">
<img src="https://git.arimelody.me/repo-avatars/6b0a1ffb78cbc6f906f83152ea42a710220174e8f48a3e44f159ae58dacd7a2f" alt="pride flag icon" aria-hidden=true class="project-icon" width="64" height="64">
<div class="project-info">
<a href="https://git.arimelody.me/ari/prideflag">pride flag</a>
<p>progressive pride flag widget for websites</p>
</div>
</li>
<li>
<a href="https://impact.arimelody.me/" target="_blank">
impact meme
</a>
<li class="project-item">
<span aria-hidden=true class="project-icon">👩‍💻</span>
<div class="project-info">
<a href="https://github.com/arimelody/ipaddrgen">ipaddrgen</a>
<p>silly hackerman IP address generator</p>
</div>
</li>
<li>
<a href="https://term.arimelody.me/" target="_blank">
OpenTerminal
</a>
<li class="project-item">
<img src="https://impact.arimelody.me/favicon.png" alt="impact meme icon" aria-hidden=true class="project-icon" width="64" height="64">
<div class="project-info">
<a href="https://impact.arimelody.me/">impact meme</a>
<p>impact meme generator</p>
</div>
</li>
<li>
<a href="https://silver.bliss.town/" target="_blank">
Silver.js
</a>
<li class="project-item">
<img src="https://codeberg.org/repo-avatars/e67303eeda4fa6d268948e71b7b0837357d8c519772701ffc36b84ae7975319f" alt="OpenTerminal icon" aria-hidden=true class="project-icon" width="64" height="64">
<div class="project-info">
<a href="https://term.arimelody.me/">OpenTerminal</a>
<p>communal online text buffer</p>
</div>
</li>
<li class="project-item">
<span aria-hidden=true class="project-icon">📜</span>
<div class="project-info">
<a href="https://silver.bliss.town/">Silver.js</a>
<p>lightweight reactive state library</p>
</div>
</li>
</ul>
@ -145,45 +202,48 @@
</h2>
<div id="web-buttons">
<a href="https://arimelody.me">
<a href="https://arimelody.space">
<img src="/img/buttons/ari melody.gif" alt="ari melody web button" width="88" height="31">
</a>
<a href="https://supitszaire.com" target="_blank">
<a href="https://supitszaire.com">
<img src="/img/buttons/zaire.gif" alt="zaire web button" width="88" height="31">
</a>
<a href="https://mae.wtf" target="_blank">
<a href="https://mae.wtf">
<img src="/img/buttons/mae.png" alt="vimae web button" width="88" height="31">
</a>
<a href="https://zvava.org" target="_blank">
<img src="/img/buttons/zvava.png" alt="zvava web button" width="88" height="31">
<a href="https://girlthi.ng/~thermia/">
<img src="/img/buttons/thermia.gif" alt="thermia web button" width="88" height="31">
</a>
<a href="https://elke.cafe" target="_blank">
<a href="https://elke.cafe">
<img src="/img/buttons/elke.gif" alt="elke web button" width="88" height="31">
</a>
<a href="https://invoxiplaygames.uk/" target="_blank">
<a href="https://invoxiplaygames.uk/">
<img src="/img/buttons/ipg.png" alt="InvoxiPlayGames web button" width="88" height="31">
</a>
<a href="https://ioletsgo.gay" target="_blank">
<a href="https://ioletsgo.gay">
<img src="/img/buttons/ioletsgo.gif" alt="ioletsgo web button" width="88" height="31">
</a>
<a href="https://notnite.com/" target="_blank">
<a href="https://notnite.com/">
<img src="/img/buttons/notnite.png" alt="notnite web button" width="88" height="31">
</a>
<a href="https://www.da.vidbuchanan.co.uk/" target="_blank">
<a href="https://www.da.vidbuchanan.co.uk/">
<img src="/img/buttons/retr0id_now.gif" alt="retr0id web button" width="88" height="31">
</a>
<a href="https://aikoyori.xyz" target="_blank">
<a href="https://aikoyori.xyz">
<img src="/img/buttons/aikoyori.gif" alt="aikoyori web button" width="88" height="31">
</a>
<a href="https://xenia.blahaj.land/" target="_blank">
<a href="https://xenia.blahaj.land/">
<img src="/img/buttons/xenia.png" alt="xenia web button" width="88" height="31">
</a>
<a href="https://stardust.elysium.gay/" target="_blank">
<a href="https://stardust.elysium.gay/">
<img src="/img/buttons/stardust.png" alt="stardust web button" width="88" height="31">
</a>
<a href="https://isabelroses.com/" target="_blank">
<a href="https://isabelroses.com/">
<img src="/img/buttons/isabelroses.gif" alt="isabel roses web button" width="88" height="31">
</a>
<a href="https://bubblegum.girlonthemoon.xyz/">
<img src="/img/buttons/girlonthemoon.png" alt="sweet like bubblegum web button" width="88" height="31">
</a>
<hr>
@ -197,14 +257,16 @@
<img src="/img/buttons/misc/sprunk.gif" alt="sprunk" width="88" height="31">
<img src="/img/buttons/misc/tohell.gif" alt="go straight to hell" width="88" height="31">
<img src="/img/buttons/misc/virusalert.gif" alt="virus alert! click here" onclick="alert('meow :3')" width="88" height="31">
<img src="/img/buttons/misc/wii.gif" alt="wii" width="88" height="31">
<a href="http://wiishopchannel.net/">
<img src="/img/buttons/misc/wii.gif" alt="wii" width="88" height="31">
</a>
<img src="/img/buttons/misc/www2.gif" alt="www" width="88" height="31">
<img src="/img/buttons/misc/iemandatory.gif" alt="get mandatory internet explorer" width="88" height="31">
<img src="/img/buttons/misc/learn_html.gif" alt="HTML - learn it today!" width="88" height="31">
<a href="https://smokepowered.com" target="_blank">
<a href="https://smokepowered.com">
<img src="/img/buttons/misc/smokepowered.gif" alt="high on SMOKE" width="88" height="31">
</a>
<a href="https://epicblazed.com" target="_blank">
<a href="https://epicblazed.com">
<img src="/img/buttons/misc/epicblazed.png" alt="epic blazed" width="88" height="31">
</a>
<img src="/img/buttons/misc/blink.gif" alt="closeup anime blink" width="88" height="31">

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
@ -10,25 +10,25 @@
{{block "head" .}}{{end}}
<script type="module", src="/script/main.js"></script>
</head>
</head>
<body>
<body>
{{template "header"}}
{{block "content" .}}
<main>
<h1>
# hello, world!
</h1>
<p>
this is a default page!
</p>
<h1>
# hello, world!
</h1>
<p>
this is a default page!
</p>
</main>
{{end}}
{{template "footer"}}
<div id="overlay"></div>
{{template "prideflag"}}
</body>
</body>
</html>

View file

@ -4,7 +4,7 @@
<meta name="description" content="Stream &quot;{{.Title}}&quot; by {{.PrintArtists true true}} on all platforms!">
<meta name="author" content="{{.PrintArtists true true}}">
<meta name="keywords" content="{{.PrintArtists true false}}, music, {{.Title}}, {{.ID}}, {{.GetReleaseYear}}">
<meta name="keywords" content="{{.PrintArtists true false}}, music, {{.Title}}, {{.ID}}, {{.ReleaseDate.Year}}">
<meta property="og:url" content="https://arimelody.me/music/{{.ID}}">
<meta property="og:type" content="website">
@ -54,7 +54,7 @@
<div id="overview">
<div id="title-container">
<h1 id="title">{{.Title}}</h1>
<span id="year" title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
<span id="year" title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
</div>
<p id="artist">{{.PrintArtists true true}}</p>
{{if .IsReleased}}
@ -91,7 +91,7 @@
{{end}}
{{if and .Copyright .CopyrightURL}}
<p id="copyright">{{.Title}} &copy; {{.GetReleaseYear}} by {{.PrintArtists true true}} is licensed under <a href="{{.CopyrightURL}}" target="_blank">{{.Copyright}}</a></p>
<p id="copyright">{{.Title}} &copy; {{.ReleaseDate.Year}} by {{.PrintArtists true true}} is licensed under <a href="{{.CopyrightURL}}" target="_blank">{{.Copyright}}</a></p>
{{end}}
<button id="share">share</button>
@ -105,8 +105,8 @@
<ul>
{{range .Credits}}
{{$Artist := .Artist}}
{{if $Artist.GetWebsite}}
<li><strong><a href="{{$Artist.GetWebsite}}">{{$Artist.Name}}</a></strong>: {{.Role}}</li>
{{if $Artist.Website}}
<li><strong><a href="{{$Artist.Website}}">{{$Artist.Name}}</a></strong>: {{.Role}}</li>
{{else}}
<li><strong>{{$Artist.Name}}</strong>: {{.Role}}</li>
{{end}}

View file

@ -1,21 +1,21 @@
{{define "prideflag"}}
<a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>
</a>
{{end}}