diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a882442
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,7 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
diff --git a/.gitignore b/.gitignore
index 9bdf788..2e63958 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ docker-compose*.yml
!docker-compose.example.yml
config*.toml
arimelody-web
+arimelody-web.tar.gz
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..11e565a
--- /dev/null
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
index e5df7f6..464379e 100644
--- a/README.md
+++ b/README.md
@@ -47,3 +47,6 @@ need to be up for this, making this ideal for some offline maintenance.
- `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts.
- `deleteAccount
{{.Title}}
- {{.GetReleaseYear}}
+ {{.ReleaseDate.Year}}
{{if not .Visible}}(hidden){{end}}
diff --git a/admin/http.go b/admin/http.go
index c70dd1d..245a152 100644
--- a/admin/http.go
+++ b/admin/http.go
@@ -1,20 +1,20 @@
package admin
import (
- "context"
- "database/sql"
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
+ "context"
+ "database/sql"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
- "arimelody-web/controller"
- "arimelody-web/log"
- "arimelody-web/model"
+ "arimelody-web/controller"
+ "arimelody-web/log"
+ "arimelody-web/model"
- "golang.org/x/crypto/bcrypt"
+ "golang.org/x/crypto/bcrypt"
)
func Handler(app *model.AppState) http.Handler {
@@ -201,7 +201,7 @@ func registerAccountHandler(app *model.AppState) http.Handler {
return
}
- app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(r))
+ app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil {
@@ -274,11 +274,20 @@ func loginHandler(app *model.AppState) http.Handler {
render()
return
}
+ if account.Locked {
+ controller.SetSessionError(app.DB, session, "This account is locked.")
+ render()
+ return
+ }
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(r))
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
+ 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
}
@@ -299,13 +308,15 @@ func loginHandler(app *model.AppState) http.Handler {
render()
return
}
+ controller.SetSessionMessage(app.DB, session, "")
+ controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
- app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(r))
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
err = controller.SetSessionAccount(app.DB, session, account)
@@ -363,7 +374,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
totpCode := r.FormValue("totp")
if len(totpCode) != controller.TOTP_CODE_LENGTH {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r))
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
@@ -377,13 +388,19 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
return
}
if totpMethod == nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r))
- controller.SetSessionError(app.DB, session, "Invalid TOTP.")
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
+ if locked := handleFailedLogin(app, session.AttemptAccount, 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(r))
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
@@ -466,7 +483,7 @@ func staticHandler() http.Handler {
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session, err := controller.GetSessionFromRequest(app.DB, r)
+ session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -496,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
+}
diff --git a/admin/logshttp.go b/admin/logshttp.go
index 93dc5b7..7249b16 100644
--- a/admin/logshttp.go
+++ b/admin/logshttp.go
@@ -1,12 +1,12 @@
package admin
import (
- "arimelody-web/log"
- "arimelody-web/model"
- "fmt"
- "net/http"
- "os"
- "strings"
+ "arimelody-web/log"
+ "arimelody-web/model"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
)
func logsHandler(app *model.AppState) http.Handler {
diff --git a/admin/releasehttp.go b/admin/releasehttp.go
index 7ef4d37..c6b68ab 100644
--- a/admin/releasehttp.go
+++ b/admin/releasehttp.go
@@ -1,12 +1,12 @@
package admin
import (
- "fmt"
- "net/http"
- "strings"
+ "fmt"
+ "net/http"
+ "strings"
- "arimelody-web/controller"
- "arimelody-web/model"
+ "arimelody-web/controller"
+ "arimelody-web/model"
)
func serveRelease(app *model.AppState) http.Handler {
diff --git a/admin/static/logs.css b/admin/static/logs.css
index 6ed91b5..f0df299 100644
--- a/admin/static/logs.css
+++ b/admin/static/logs.css
@@ -71,6 +71,7 @@ th.log-type {
td.log-content,
td.log-content {
width: 100%;
+ white-space: collapse;
}
.log:hover {
diff --git a/admin/templates.go b/admin/templates.go
index 12cdf08..606d569 100644
--- a/admin/templates.go
+++ b/admin/templates.go
@@ -1,12 +1,12 @@
package admin
import (
- "arimelody-web/log"
- "fmt"
- "html/template"
- "path/filepath"
- "strings"
- "time"
+ "arimelody-web/log"
+ "fmt"
+ "html/template"
+ "path/filepath"
+ "strings"
+ "time"
)
var indexTemplate = template.Must(template.ParseFiles(
diff --git a/admin/trackhttp.go b/admin/trackhttp.go
index a92f81a..93eacdb 100644
--- a/admin/trackhttp.go
+++ b/admin/trackhttp.go
@@ -1,9 +1,9 @@
package admin
import (
- "fmt"
- "net/http"
- "strings"
+ "fmt"
+ "net/http"
+ "strings"
"arimelody-web/model"
"arimelody-web/controller"
diff --git a/api/api.go b/api/api.go
index d3c83ce..398db4b 100644
--- a/api/api.go
+++ b/api/api.go
@@ -1,15 +1,15 @@
package api
import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "os"
- "strings"
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
- "arimelody-web/controller"
- "arimelody-web/model"
+ "arimelody-web/controller"
+ "arimelody-web/model"
)
func Handler(app *model.AppState) http.Handler {
diff --git a/api/artist.go b/api/artist.go
index 9006cc3..01899a6 100644
--- a/api/artist.go
+++ b/api/artist.go
@@ -1,66 +1,66 @@
package api
import (
- "encoding/json"
- "fmt"
- "io/fs"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
- "arimelody-web/controller"
- "arimelody-web/log"
- "arimelody-web/model"
+ "arimelody-web/controller"
+ "arimelody-web/log"
+ "arimelody-web/model"
)
func ServeAllArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artists = []*model.Artist{}
artists, err := controller.GetAllArtists(app.DB)
- if err != nil {
+ if err != nil {
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
- w.Header().Add("Content-Type", "application/json")
+ w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(artists)
- if err != nil {
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
})
}
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- type (
- creditJSON struct {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ type (
+ creditJSON struct {
ID string `json:"id"`
Title string `json:"title"`
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
Artwork string `json:"artwork"`
- Role string `json:"role"`
- Primary bool `json:"primary"`
- }
- artistJSON struct {
- *model.Artist
- Credits map[string]creditJSON `json:"credits"`
- }
- )
+ Role string `json:"role"`
+ Primary bool `json:"primary"`
+ }
+ artistJSON struct {
+ *model.Artist
+ Credits map[string]creditJSON `json:"credits"`
+ }
+ )
session := r.Context().Value("session").(*model.Session)
show_hidden_releases := session != nil && session.Account != nil
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
- if err != nil {
+ if err != nil {
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
var credits = map[string]creditJSON{}
for _, credit := range dbCredits {
@@ -74,17 +74,17 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
}
}
- w.Header().Add("Content-Type", "application/json")
+ w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(artistJSON{
Artist: artist,
Credits: credits,
})
- if err != nil {
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- })
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ })
}
func CreateArtist(app *model.AppState) http.Handler {
diff --git a/api/release.go b/api/release.go
index efed8dd..e07f0d7 100644
--- a/api/release.go
+++ b/api/release.go
@@ -1,26 +1,26 @@
package api
import (
- "encoding/json"
- "fmt"
- "io/fs"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
- "arimelody-web/controller"
- "arimelody-web/log"
- "arimelody-web/model"
+ "arimelody-web/controller"
+ "arimelody-web/log"
+ "arimelody-web/model"
)
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases
privileged := false
if !release.Visible {
- session, err := controller.GetSessionFromRequest(app.DB, r)
+ session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -116,15 +116,15 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
}
}
- w.Header().Add("Content-Type", "application/json")
+ w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err := encoder.Encode(response)
- if err != nil {
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- })
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ })
}
func ServeCatalog(app *model.AppState) http.Handler {
diff --git a/api/track.go b/api/track.go
index e7d7c07..4e48418 100644
--- a/api/track.go
+++ b/api/track.go
@@ -1,13 +1,13 @@
package api
import (
- "encoding/json"
- "fmt"
- "net/http"
+ "encoding/json"
+ "fmt"
+ "net/http"
- "arimelody-web/controller"
- "arimelody-web/log"
- "arimelody-web/model"
+ "arimelody-web/controller"
+ "arimelody-web/log"
+ "arimelody-web/model"
)
type (
@@ -29,7 +29,7 @@ func ServeAllTracks(app *model.AppState) http.Handler {
dbTracks, err := controller.GetAllTracks(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
for _, track := range dbTracks {
@@ -39,23 +39,23 @@ func ServeAllTracks(app *model.AppState) http.Handler {
})
}
- w.Header().Add("Content-Type", "application/json")
+ w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(tracks)
- if err != nil {
+ if err != nil {
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
})
}
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
releases := []string{}
@@ -63,15 +63,15 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
releases = append(releases, release.ID)
}
- w.Header().Add("Content-Type", "application/json")
+ w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(Track{ track, releases })
- if err != nil {
+ if err != nil {
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- })
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ })
}
func CreateTrack(app *model.AppState) http.Handler {
diff --git a/api/uploads.go b/api/uploads.go
index 60ab7dd..4678f22 100644
--- a/api/uploads.go
+++ b/api/uploads.go
@@ -1,56 +1,56 @@
package api
import (
- "arimelody-web/log"
- "arimelody-web/model"
- "bufio"
- "encoding/base64"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
+ "arimelody-web/log"
+ "arimelody-web/model"
+ "bufio"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
)
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
- split := strings.Split(*data, ";base64,")
- header := split[0]
- imageData, err := base64.StdEncoding.DecodeString(split[1])
- ext, _ := strings.CutPrefix(header, "data:image/")
+ split := strings.Split(*data, ";base64,")
+ header := split[0]
+ imageData, err := base64.StdEncoding.DecodeString(split[1])
+ ext, _ := strings.CutPrefix(header, "data:image/")
directory = filepath.Join(app.Config.DataDirectory, directory)
- switch ext {
- case "png":
- case "jpg":
- case "jpeg":
- default:
- return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
- }
+ switch ext {
+ case "png":
+ case "jpg":
+ case "jpeg":
+ default:
+ return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
+ }
filename = fmt.Sprintf("%s.%s", filename, ext)
- // ensure directory exists
- os.MkdirAll(directory, os.ModePerm)
+ // ensure directory exists
+ os.MkdirAll(directory, os.ModePerm)
- imagePath := filepath.Join(directory, filename)
- file, err := os.Create(imagePath)
- if err != nil {
- return "", err
- }
- defer file.Close()
+ imagePath := filepath.Join(directory, filename)
+ file, err := os.Create(imagePath)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
// TODO: generate compressed versions of image (512x512?)
- buffer := bufio.NewWriter(file)
- _, err = buffer.Write(imageData)
- if err != nil {
+ buffer := bufio.NewWriter(file)
+ _, err = buffer.Write(imageData)
+ if err != nil {
return "", nil
- }
+ }
- if err := buffer.Flush(); err != nil {
+ if err := buffer.Flush(); err != nil {
return "", nil
- }
+ }
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
- return filename, nil
+ return filename, nil
}
diff --git a/bundle.sh b/bundle.sh
deleted file mode 100755
index dc7c023..0000000
--- a/bundle.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/bash
-# simple script to pack up arimelody.me for production distribution
-
-if [ ! -f arimelody-web ]; then
- echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first."
- exit 1
-fi
-
-tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/
diff --git a/controller/account.go b/controller/account.go
index 9c7c1e1..ab64ca5 100644
--- a/controller/account.go
+++ b/controller/account.go
@@ -1,10 +1,10 @@
package controller
import (
- "arimelody-web/model"
- "strings"
+ "arimelody-web/model"
+ "strings"
- "github.com/jmoiron/sqlx"
+ "github.com/jmoiron/sqlx"
)
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
@@ -110,3 +110,26 @@ func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}
+
+func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
+ failAttempts := 0
+ err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
+ if err != nil { return false, err }
+ locked := false
+ if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
+ err = LockAccount(db, accountID)
+ if err != nil { return false, err }
+ locked = true
+ }
+ return locked, err
+}
+
+func LockAccount(db *sqlx.DB, accountID string) error {
+ _, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
+ return err
+}
+
+func UnlockAccount(db *sqlx.DB, accountID string) error {
+ _, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
+ return err
+}
diff --git a/controller/artist.go b/controller/artist.go
index 1a613aa..f086778 100644
--- a/controller/artist.go
+++ b/controller/artist.go
@@ -1,48 +1,48 @@
package controller
import (
- "arimelody-web/model"
+ "arimelody-web/model"
- "github.com/jmoiron/sqlx"
+ "github.com/jmoiron/sqlx"
)
// DATABASE
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
- var artist = model.Artist{}
+ var artist = model.Artist{}
- err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
- if err != nil {
- return nil, err
- }
+ err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
+ if err != nil {
+ return nil, err
+ }
- return &artist, nil
+ return &artist, nil
}
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
- var artists = []*model.Artist{}
+ var artists = []*model.Artist{}
- err := db.Select(&artists, "SELECT * FROM artist")
- if err != nil {
- return nil, err
- }
+ err := db.Select(&artists, "SELECT * FROM artist")
+ if err != nil {
+ return nil, err
+ }
- return artists, nil
+ return artists, nil
}
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
- var artists = []*model.Artist{}
+ var artists = []*model.Artist{}
- err := db.Select(&artists,
+ err := db.Select(&artists,
"SELECT * FROM artist "+
"WHERE id NOT IN "+
"(SELECT artist FROM musiccredit WHERE release=$1)",
releaseID)
- if err != nil {
- return nil, err
- }
+ if err != nil {
+ return nil, err
+ }
- return artists, nil
+ return artists, nil
}
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
@@ -54,9 +54,9 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.
if !show_hidden { query += "AND visible=true " }
query += "ORDER BY release_date DESC"
rows, err := db.Query(query, artistID)
- if err != nil {
- return nil, err
- }
+ if err != nil {
+ return nil, err
+ }
defer rows.Close()
type NamePrimary struct {
@@ -102,13 +102,13 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.
func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec(
- "INSERT INTO artist (id, name, website, avatar) "+
+ "INSERT INTO artist (id, name, website, avatar) "+
"VALUES ($1, $2, $3, $4)",
- artist.ID,
- artist.Name,
- artist.Website,
+ artist.ID,
+ artist.Name,
+ artist.Website,
artist.Avatar,
- )
+ )
if err != nil {
return err
}
diff --git a/controller/config.go b/controller/config.go
index 8772b6b..fdfa756 100644
--- a/controller/config.go
+++ b/controller/config.go
@@ -1,14 +1,14 @@
package controller
import (
- "errors"
- "fmt"
- "os"
- "strconv"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
- "arimelody-web/model"
+ "arimelody-web/model"
- "github.com/pelletier/go-toml/v2"
+ "github.com/pelletier/go-toml/v2"
)
func GetConfig() model.Config {
@@ -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
}
diff --git a/controller/invite.go b/controller/invite.go
index f30db64..a7bde40 100644
--- a/controller/invite.go
+++ b/controller/invite.go
@@ -1,12 +1,12 @@
package controller
import (
- "arimelody-web/model"
- "math/rand"
- "strings"
- "time"
+ "arimelody-web/model"
+ "math/rand"
+ "strings"
+ "time"
- "github.com/jmoiron/sqlx"
+ "github.com/jmoiron/sqlx"
)
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
diff --git a/controller/ip.go b/controller/ip.go
index 4b1126d..cbc3054 100644
--- a/controller/ip.go
+++ b/controller/ip.go
@@ -1,19 +1,23 @@
package controller
import (
- "net/http"
- "slices"
+ "arimelody-web/model"
+ "net/http"
+ "slices"
+ "strings"
)
// Returns the request's original IP address, resolving the `x-forwarded-for`
// header if the request originates from a trusted proxy.
-func ResolveIP(r *http.Request) string {
- trustedProxies := []string{ "10.4.20.69" }
- if slices.Contains(trustedProxies, r.RemoteAddr) {
+func ResolveIP(app *model.AppState, r *http.Request) string {
+ addr := strings.Split(r.RemoteAddr, ":")[0]
+ if slices.Contains(app.Config.TrustedProxies, addr) {
forwardedFor := r.Header.Get("x-forwarded-for")
if len(forwardedFor) > 0 {
+ // discard extra IPs; cloudflare tends to append their nodes
+ forwardedFor = strings.Split(forwardedFor, ", ")[0]
return forwardedFor
}
}
- return r.RemoteAddr
+ return addr
}
diff --git a/controller/migrator.go b/controller/migrator.go
index b053a27..4b99b9c 100644
--- a/controller/migrator.go
+++ b/controller/migrator.go
@@ -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 = 3
+const DB_VERSION int = 4
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@@ -45,6 +45,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "002-audit-logs")
oldDBVersion = 3
+ case 3:
+ ApplyMigration(db, "003-fail-lock")
+ oldDBVersion = 4
+
}
}
diff --git a/controller/qr.go b/controller/qr.go
index 7ada0f8..dd08637 100644
--- a/controller/qr.go
+++ b/controller/qr.go
@@ -1,13 +1,9 @@
package controller
import (
- "bytes"
- "encoding/base64"
- "errors"
- "fmt"
- "image"
- "image/color"
- "image/png"
+ "encoding/base64"
+ "image"
+ "image/color"
"github.com/skip2/go-qrcode"
)
@@ -33,69 +29,6 @@ const (
HIGH
)
-func noDepsGenerateQRCode() (string, error) {
- version := 1
-
- size := 0
- size = 21 + version * 4
- if version > 10 {
- return "", errors.New(fmt.Sprintf("QR version %d not supported", version))
- }
-
- img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2))
-
- // fill white
- for y := range size + margin * 2 {
- for x := range size + margin * 2 {
- img.Set(x, y, color.White)
- }
- }
-
- // draw alignment squares
- drawLargeAlignmentSquare(margin, margin, img)
- drawLargeAlignmentSquare(margin, margin + size - 7, img)
- drawLargeAlignmentSquare(margin + size - 7, margin, img)
- drawSmallAlignmentSquare(size - 5, size - 5, img)
- /*
- if version > 4 {
- space := version * 3 - 2
- end := size / space
- for y := range size / space + 1 {
- for x := range size / space + 1 {
- if x == 0 && y == 0 { continue }
- if x == 0 && y == end { continue }
- if x == end && y == 0 { continue }
- if x == end && y == end { continue }
- drawSmallAlignmentSquare(
- x * space + margin + 4,
- y * space + margin + 4,
- img,
- )
- }
- }
- }
- */
-
- // draw timing bits
- for i := margin + 6; i < size - 4; i++ {
- if (i % 2 == 0) {
- img.Set(i, margin + 6, color.Black)
- img.Set(margin + 6, i, color.Black)
- }
- }
- img.Set(margin + 8, size - 4, color.Black)
-
- var imgBuf bytes.Buffer
- err := png.Encode(&imgBuf, img)
- if err != nil {
- return "", err
- }
-
- base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes())
-
- return "data:image/png;base64," + base64Img, nil
-}
-
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 7 {
for xi := range 7 {
diff --git a/controller/release.go b/controller/release.go
index 362669a..3dcad26 100644
--- a/controller/release.go
+++ b/controller/release.go
@@ -1,12 +1,12 @@
package controller
import (
- "errors"
- "fmt"
+ "errors"
+ "fmt"
- "arimelody-web/model"
+ "arimelody-web/model"
- "github.com/jmoiron/sqlx"
+ "github.com/jmoiron/sqlx"
)
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
diff --git a/controller/session.go b/controller/session.go
index cf423fe..5028789 100644
--- a/controller/session.go
+++ b/controller/session.go
@@ -1,21 +1,22 @@
package controller
import (
- "database/sql"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "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(db *sqlx.DB, r *http.Request) (*model.Session, error) {
+func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
@@ -25,14 +26,26 @@ func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error)
if sessionCookie != nil {
// fetch existing session
- session, err = GetSession(db, sessionCookie.Value)
+ session, err = GetSession(app.DB, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
}
if session != nil {
- // TODO: consider running security checks here (i.e. user agent mismatches)
+ if session.UserAgent != r.UserAgent() {
+ msg := "Session user agent mismatch. A cookie may have been hijacked!"
+ if session.Account != nil {
+ account, _ := GetAccountByID(app.DB, session.Account.ID)
+ msg += " (Account \"" + account.Username + "\")"
+ }
+ app.Log.Warn(log.TYPE_ACCOUNT, msg)
+ err = DeleteSession(app.DB, session.Token)
+ if err != nil {
+ app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
+ }
+ return nil, nil
+ }
}
}
@@ -175,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
a test
description!"
+ got := release.GetDescriptionHTML()
+ if want != string(got) {
+ t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got)
+ }
+}
+
+func Test_Release_ReleaseDate(t *testing.T) {
+ release := Release{
+ ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC),
+ }
+
+ want := "2025-07-26T16:00"
+ got := release.TextReleaseDate()
+ if want != got {
+ t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got)
+ }
+
+ want = "26 July 2025"
+ got = release.PrintReleaseDate()
+ if want != got {
+ t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got)
+ }
+}
+
+func Test_Release_Artwork(t *testing.T) {
+ want := "testartwork.png"
+ release := Release{ Artwork: want }
+
+ got := release.GetArtwork()
+ if want != got {
+ t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got)
+ }
+
+ release = Release{}
+
+ want = "/img/default-cover-art.png"
+ got = release.GetArtwork()
+ if want != got {
+ t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got)
+ }
+}
+
+func Test_Release_IsSingle(t *testing.T) {
+ release := Release{
+ Tracks: []*Track{},
+ }
+
+ if release.IsSingle() {
+ t.Errorf("IsSingle() == true when no tracks are present")
+ }
+
+ release.Tracks = append(release.Tracks, &Track{})
+ if !release.IsSingle() {
+ t.Errorf("IsSingle() == false when one track is present")
+ }
+
+ release.Tracks = append(release.Tracks, &Track{})
+ if release.IsSingle() {
+ t.Errorf("IsSingle() == true when >1 tracks are present")
+ }
+}
+
+func Test_Release_IsReleased(t *testing.T) {
+ release := Release {
+ ReleaseDate: time.Now(),
+ }
+
+ if !release.IsReleased() {
+ t.Errorf("IsRelease() == false when release date in the past")
+ }
+
+ release.ReleaseDate = time.Now().Add(time.Hour)
+ if release.IsReleased() {
+ t.Errorf("IsRelease() == true when release date in the future")
+ }
+}
+
+func Test_Release_PrintArtists(t *testing.T) {
+ artist1 := "ari melody"
+ artist2 := "aridoodle"
+ artist3 := "idk"
+ artist4 := "guest"
+
+ release := Release {
+ Credits: []*Credit{
+ { Artist: Artist{ Name: artist1 }, Primary: true },
+ { Artist: Artist{ Name: artist2 }, Primary: true },
+ { Artist: Artist{ Name: artist3 }, Primary: false },
+ { Artist: Artist{ Name: artist4 }, Primary: true },
+ },
+ }
+
+ {
+ want := []string{ artist1, artist2, artist4 }
+ got := release.GetUniqueArtistNames(true)
+ if len(want) != len(got) {
+ t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want))
+ }
+ for i := range got {
+ if want[i] != got[i] {
+ t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i])
+ }
+ }
+
+ want = []string{ artist1, artist2, artist3, artist4 }
+ got = release.GetUniqueArtistNames(false)
+ if len(want) != len(got) {
+ t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want))
+ }
+ for i := range got {
+ if want[i] != got[i] {
+ t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i])
+ }
+ }
+ }
+
+ {
+ want := "ari melody, aridoodle & guest"
+ got := release.PrintArtists(true, true)
+ if want != got {
+ t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got)
+ }
+
+ want = "ari melody, aridoodle, guest"
+ got = release.PrintArtists(true, false)
+ if want != got {
+ t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got)
+ }
+
+ want = "ari melody, aridoodle, idk & guest"
+ got = release.PrintArtists(false, true)
+ if want != got {
+ t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got)
+ }
+
+ want = "ari melody, aridoodle, idk, guest"
+ got = release.PrintArtists(false, false)
+ if want != got {
+ t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got)
+ }
+ }
+}
diff --git a/model/session.go b/model/session.go
index de016e1..7382de3 100644
--- a/model/session.go
+++ b/model/session.go
@@ -1,8 +1,8 @@
package model
import (
- "database/sql"
- "time"
+ "database/sql"
+ "time"
)
type Session struct {
diff --git a/model/totp.go b/model/totp.go
index cfad10a..108dae5 100644
--- a/model/totp.go
+++ b/model/totp.go
@@ -1,7 +1,7 @@
package model
import (
- "time"
+ "time"
)
type TOTP struct {
diff --git a/model/track.go b/model/track.go
index ca54ddd..deaf086 100644
--- a/model/track.go
+++ b/model/track.go
@@ -1,20 +1,20 @@
package model
import (
- "html/template"
- "strings"
+ "html/template"
+ "strings"
)
type (
- Track struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Description string `json:"description"`
+ Track struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
Lyrics string `json:"lyrics" db:"lyrics"`
- PreviewURL string `json:"previewURL" db:"preview_url"`
+ PreviewURL string `json:"previewURL" db:"preview_url"`
Number int
- }
+ }
)
func (track Track) GetDescriptionHTML() template.HTML {
diff --git a/model/track_test.go b/model/track_test.go
new file mode 100644
index 0000000..fd500d7
--- /dev/null
+++ b/model/track_test.go
@@ -0,0 +1,43 @@
+package model
+
+import (
+ "testing"
+)
+
+func Test_Track_DescriptionHTML(t *testing.T) {
+ track := Track{
+ Description: "this is\na test\ndescription!",
+ }
+
+ // descriptions are set by privileged users,
+ // so we'll allow HTML injection here
+ want := "this is
a test
description!"
+ got := track.GetDescriptionHTML()
+ if want != string(got) {
+ t.Errorf(`track description incorrectly formatted (want "%s", got "%s")`, want, got)
+ }
+}
+
+func Test_Track_LyricsHTML(t *testing.T) {
+ track := Track{
+ Lyrics: "these are\ntest\nlyrics!",
+ }
+
+ // lyrics are set by privileged users,
+ // so we'll allow HTML injection here
+ want := "these are
test
lyrics!"
+ got := track.GetLyricsHTML()
+ if want != string(got) {
+ t.Errorf(`track lyrics incorrectly formatted (want "%s", got "%s")`, want, got)
+ }
+}
+
+func Test_Track_Add(t *testing.T) {
+ track := Track{}
+
+ want := 4
+ got := track.Add(2, 2)
+ if want != got {
+ t.Errorf(`somehow, we screwed up addition. (want %d, got %d)`, want, got)
+ }
+}
diff --git a/model/twitch.go b/model/twitch.go
new file mode 100644
index 0000000..6bca17d
--- /dev/null
+++ b/model/twitch.go
@@ -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
+}
diff --git a/public/img/brand/bandcamp.svg b/public/img/brand/bandcamp.svg
new file mode 100644
index 0000000..9623ec2
--- /dev/null
+++ b/public/img/brand/bandcamp.svg
@@ -0,0 +1,10 @@
+
+
+
diff --git a/public/img/brand/bluesky.svg b/public/img/brand/bluesky.svg
new file mode 100644
index 0000000..d77fafe
--- /dev/null
+++ b/public/img/brand/bluesky.svg
@@ -0,0 +1,10 @@
+
+
+
diff --git a/public/img/brand/codeberg.svg b/public/img/brand/codeberg.svg
new file mode 100644
index 0000000..028b729
--- /dev/null
+++ b/public/img/brand/codeberg.svg
@@ -0,0 +1,164 @@
+
+
diff --git a/public/img/brand/discord.svg b/public/img/brand/discord.svg
new file mode 100644
index 0000000..b636d15
--- /dev/null
+++ b/public/img/brand/discord.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/img/brand/twitch.svg b/public/img/brand/twitch.svg
new file mode 100644
index 0000000..3120fea
--- /dev/null
+++ b/public/img/brand/twitch.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/public/img/brand/youtube.svg b/public/img/brand/youtube.svg
new file mode 100644
index 0000000..3286071
--- /dev/null
+++ b/public/img/brand/youtube.svg
@@ -0,0 +1,10 @@
+
+
+
diff --git a/public/img/buttons/girlonthemoon.png b/public/img/buttons/girlonthemoon.png
new file mode 100644
index 0000000..4e98a12
Binary files /dev/null and b/public/img/buttons/girlonthemoon.png differ
diff --git a/public/img/buttons/thermia.gif b/public/img/buttons/thermia.gif
new file mode 100644
index 0000000..a7ee70a
Binary files /dev/null and b/public/img/buttons/thermia.gif differ
diff --git a/public/keys/ari melody_0x92678188_public.asc b/public/keys/ari melody_0x92678188_public.asc
index 2b37d88..80a4676 100644
--- a/public/keys/ari melody_0x92678188_public.asc
+++ b/public/keys/ari melody_0x92678188_public.asc
@@ -1,13 +1,26 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
-JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJMEExYKADsWIQTu
-jeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbAwULCQgHAgIiAgYVCgkICwIEFgID
-AQIeBwIXgAAKCRDPmYKckmeBiGCbAP4wTcLCU5ZlfSTJrFtGhQKWA6DxtUO7Cegk
-Vu8SgkY3KgEA1/YqjZ1vSaqPDN4137vmhkhfduoYOjN0iptNj39u2wG4OARk1bTd
-EgorBgEEAZdVAQUBAQdAnA2drPzQBoXNdwIrFnovuF0CjX+8+8QSugCF4a5ZEXED
-AQgHiHgEGBYKACAWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbDAAKCRDP
-mYKckmeBiC/xAQD1hu4WcstR40lkUxMqhZ44wmizrDA+eGCdh7Ge3Gy79wEAx385
-GnYoNplMTA4BTGs7orV4WSfSkoBx0+px1UOewgs=
-=M1Bp
+JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF
+CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB
+iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ
++oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI
+kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC
+BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF
+ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P
+f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL
+d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq
+VgUJBaOeTACBdiAEGRYKAB0WIQQlu5dWmBR/P3ZxngxgtfA4bj3bfgUCZ7UmigAK
+CRBgtfA4bj3bfux+AP4y5ydrjnGBMX7GuB2nh55SRdscSiXsZ66ntnjXyQcbWgEA
+pDuu7FqXzXcnluuZxNFDT740Rnzs60tTeplDqGGWcAQJEM+ZgpySZ4GIc0kA/iSw
+Nw+r3FC75omwrPpJF13B5fq93FweFx+oSaES6qzkAQDvgCK77qKKbvCju0g8zSsK
+EZnv6xR4uvtGdVkvLpBdC7gzBGe1JpkWCSsGAQQB2kcPAQEHQGnU4lXFLchhKYkC
+PshP+jvuRsNoedaDOK2p4dkQC8JuiH4EGBYKACYCGyAWIQTujeuNYocuegkeKt/P
+mYKckmeBiAUCZ7UqXgUJBaOeRQAKCRDPmYKckmeBiL9KAQCJZIBhuSsoYa61I0XZ
+cKzGZbB0h9pD6eg1VRswNIgHtQEAwu9Hgs1rs9cySvKbO7WgK6Qh6EfrvGgGOXCO
+m3wVsg24OARntSo5EgorBgEEAZdVAQUBAQdA+/k586W1OHxndzDJNpbd+wqjyjr0
+D5IXxfDs00advB0DAQgHiH4EGBYKACYWIQTujeuNYocuegkeKt/PmYKckmeBiAUC
+Z7UqOQIbDAUJBaOagAAKCRDPmYKckmeBiEFxAQCgziQt2l3u7jnZVij4zop+K2Lv
+TVFtkbG61tf6brRzBgD/X6c6X5BRyQC51JV1I1RFRBdeMAIXzcLFg2v3WUMccQs=
+=YmHI
-----END PGP PUBLIC KEY BLOCK-----
diff --git a/public/script/config.js b/public/script/config.js
index 2bb33a6..402a74b 100644
--- a/public/script/config.js
+++ b/public/script/config.js
@@ -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
# hello, world!
@@ -33,7 +50,7 @@
- i'm a musician, developer, + i'm a musician, developer, streamer, youtuber, and probably a bunch of other things i forgot to mention!
@@ -44,7 +61,7 @@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 - bandcamp + bandcamp so you can at least get something for your money. thank you very much either way!! 💕
@@ -84,57 +101,97 @@where to find me 🛰️
-projects i've worked on 🛠️
-minecraft server query utility
+watch the cat dance 🐱
+progressive pride flag widget for websites
+silly hackerman IP address generator
+impact meme generator
+communal online text buffer
+lightweight reactive state library
+