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/README.md b/README.md index e5df7f6..464379e 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,6 @@ need to be up for this, making this ideal for some offline maintenance. - `purgeInvites`: Deletes all available invite codes. - `listAccounts`: Lists all active accounts. - `deleteAccount `: Deletes an account with a given `username`. +- `lockAccount `: Locks the account under `username`. +- `unlockAccount `: Unlocks the account under `username`. +- `logs`: Shows system logs. diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 125abdc..945a507 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,17 +1,17 @@ package admin import ( - "database/sql" - "fmt" - "net/http" - "net/url" - "os" + "database/sql" + "fmt" + "net/http" + "net/url" + "os" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) func accountHandler(app *model.AppState) http.Handler { diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 6dfbbfd..9fa6bb2 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -1,9 +1,9 @@ package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" "arimelody-web/model" "arimelody-web/controller" diff --git a/admin/components/release/release-list-item.html b/admin/components/release/release-list-item.html index 677318d..4b8f41e 100644 --- a/admin/components/release/release-list-item.html +++ b/admin/components/release/release-list-item.html @@ -7,7 +7,7 @@

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

diff --git a/admin/http.go b/admin/http.go index b16c209..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 { @@ -274,11 +274,20 @@ func loginHandler(app *model.AppState) http.Handler { render() return } + if account.Locked { + controller.SetSessionError(app.DB, session, "This account is locked.") + render() + return + } err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) - controller.SetSessionError(app.DB, session, "Invalid username or password.") + if locked := handleFailedLogin(app, account, 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,6 +308,8 @@ func loginHandler(app *model.AppState) http.Handler { render() return } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin/totp", http.StatusFound) return } @@ -377,8 +388,14 @@ func loginTOTPHandler(app *model.AppState) http.Handler { return } if totpMethod == nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) - controller.SetSessionError(app.DB, session, "Invalid TOTP.") + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + if locked := handleFailedLogin(app, session.AttemptAccount, 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 } @@ -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/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/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 5a69d49..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 { @@ -77,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 233d76a..cbc3054 100644 --- a/controller/ip.go +++ b/controller/ip.go @@ -1,10 +1,10 @@ package controller import ( - "arimelody-web/model" - "net/http" - "slices" - "strings" + "arimelody-web/model" + "net/http" + "slices" + "strings" ) // Returns the request's original IP address, resolving the `x-forwarded-for` diff --git a/controller/migrator.go b/controller/migrator.go index 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:\n\tDeletes an account with a given `username`.\n", + "deleteAccount :\n\tDeletes the account under `username`.\n", + "lockAccount :\n\tLocks the account under `username`.\n", + "unlockAccount :\n\tUnlocks the account under `username`.\n", + "logs:\n\tShows system logs.\n", ) return } @@ -397,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") @@ -417,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 { @@ -448,49 +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("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) mux.Handle("/cursor-ws", cursor.Handler(app)) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead { - w.WriteHeader(http.StatusOK) - return - } - - if r.URL.Path == "/" || r.URL.Path == "/index.html" { - err := templates.IndexTemplate.Execute(w, nil) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - return - } - staticHandler("public").ServeHTTP(w, r) - })) + 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", diff --git a/model/account.go b/model/account.go index 166e880..67424b7 100644 --- a/model/account.go +++ b/model/account.go @@ -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"` } diff --git a/model/appstate.go b/model/appstate.go index 233e0db..3a1c230 100644 --- a/model/appstate.go +++ b/model/appstate.go @@ -1,7 +1,7 @@ package model import ( - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" "arimelody-web/log" ) @@ -21,6 +21,12 @@ 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"` @@ -28,12 +34,14 @@ type ( 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 } ) diff --git a/model/artist.go b/model/artist.go index 733e537..746a7dd 100644 --- a/model/artist.go +++ b/model/artist.go @@ -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 } diff --git a/model/artist_test.go b/model/artist_test.go new file mode 100644 index 0000000..feb9a18 --- /dev/null +++ b/model/artist_test.go @@ -0,0 +1,23 @@ +package model + +import ( + "testing" +) + +func Test_Artist_GetAvatar(t *testing.T) { + want := "testavatar.png" + artist := Artist{ Avatar: want } + + got := artist.GetAvatar() + if want != got { + t.Errorf(`correct value not returned when avatar is populated (want "%s", got "%s")`, want, got) + } + + artist = Artist{} + + want = "/img/default-avatar.png" + got = artist.GetAvatar() + if want != got { + t.Errorf(`default value not returned when avatar is empty (want "%s", got "%s")`, want, got) + } +} diff --git a/model/link.go b/model/link.go index 8b48ced..ba83e22 100644 --- a/model/link.go +++ b/model/link.go @@ -1,16 +1,16 @@ package model import ( - "regexp" - "strings" + "regexp" + "strings" ) type Link struct { - Name string `json:"name"` - URL string `json:"url"` + Name string `json:"name"` + URL string `json:"url"` } func (link Link) NormaliseName() string { - rgx := regexp.MustCompile(`[^a-z0-9]`) - return strings.ToLower(rgx.ReplaceAllString(link.Name, "")) + rgx := regexp.MustCompile(`[^a-z0-9\-]`) + return rgx.ReplaceAllString(strings.ToLower(link.Name), "") } diff --git a/model/link_test.go b/model/link_test.go new file mode 100644 index 0000000..b368094 --- /dev/null +++ b/model/link_test.go @@ -0,0 +1,23 @@ +package model + +import ( + "testing" +) + +func Test_Link_NormaliseName(t *testing.T) { + link := Link{ + Name: "!c@o#o$l%-^a&w*e(s)o_m=e+-[l{i]n}k-0123456789ABCDEF", + } + + want := "cool-awesome-link-0123456789abcdef" + got := link.NormaliseName() + if want != got { + t.Errorf(`name with invalid characters not properly formatted (want "%s", got "%s")`, want, got) + } + + link.Name = want + got = link.NormaliseName() + if want != got { + t.Errorf(`valid name mangled by formatter (want "%s", got "%s")`, want, got) + } +} diff --git a/model/release.go b/model/release.go index 42d6fba..e64317b 100644 --- a/model/release.go +++ b/model/release.go @@ -1,9 +1,9 @@ package model import ( - "html/template" - "strings" - "time" + "html/template" + "strings" + "time" ) type ( @@ -50,10 +50,6 @@ func (release Release) PrintReleaseDate() string { return release.ReleaseDate.Format("02 January 2006") } -func (release Release) GetReleaseYear() int { - return release.ReleaseDate.Year() -} - func (release Release) GetArtwork() string { if release.Artwork == "" { return "/img/default-cover-art.png" @@ -77,23 +73,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string { names = append(names, credit.Artist.Name) } - return names + return names } func (release Release) PrintArtists(only_primary bool, ampersand bool) string { names := release.GetUniqueArtistNames(only_primary) - if len(names) == 0 { - return "Unknown Artist" - } else if len(names) == 1 { - return names[0] - } + if len(names) == 0 { + return "Unknown Artist" + } else if len(names) == 1 { + return names[0] + } - if ampersand { - res := strings.Join(names[:len(names)-1], ", ") - res += " & " + names[len(names)-1] - return res - } else { - return strings.Join(names[:], ", ") - } + if ampersand { + res := strings.Join(names[:len(names)-1], ", ") + res += " & " + names[len(names)-1] + return res + } else { + return strings.Join(names[:], ", ") + } } diff --git a/model/release_test.go b/model/release_test.go new file mode 100644 index 0000000..b0ddaf5 --- /dev/null +++ b/model/release_test.go @@ -0,0 +1,157 @@ +package model + +import ( + "testing" + "time" +) + +func Test_Release_DescriptionHTML(t *testing.T) { + release := Release{ + Description: "this is\na test\ndescription!", + } + + // descriptions are set by privileged users, + // so we'll allow HTML injection here + want := "this is
a test
description!" + got := release.GetDescriptionHTML() + if want != string(got) { + t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_ReleaseDate(t *testing.T) { + release := Release{ + ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), + } + + want := "2025-07-26T16:00" + got := release.TextReleaseDate() + if want != got { + t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got) + } + + want = "26 July 2025" + got = release.PrintReleaseDate() + if want != got { + t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_Artwork(t *testing.T) { + want := "testartwork.png" + release := Release{ Artwork: want } + + got := release.GetArtwork() + if want != got { + t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got) + } + + release = Release{} + + want = "/img/default-cover-art.png" + got = release.GetArtwork() + if want != got { + t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_IsSingle(t *testing.T) { + release := Release{ + Tracks: []*Track{}, + } + + if release.IsSingle() { + t.Errorf("IsSingle() == true when no tracks are present") + } + + release.Tracks = append(release.Tracks, &Track{}) + if !release.IsSingle() { + t.Errorf("IsSingle() == false when one track is present") + } + + release.Tracks = append(release.Tracks, &Track{}) + if release.IsSingle() { + t.Errorf("IsSingle() == true when >1 tracks are present") + } +} + +func Test_Release_IsReleased(t *testing.T) { + release := Release { + ReleaseDate: time.Now(), + } + + if !release.IsReleased() { + t.Errorf("IsRelease() == false when release date in the past") + } + + release.ReleaseDate = time.Now().Add(time.Hour) + if release.IsReleased() { + t.Errorf("IsRelease() == true when release date in the future") + } +} + +func Test_Release_PrintArtists(t *testing.T) { + artist1 := "ari melody" + artist2 := "aridoodle" + artist3 := "idk" + artist4 := "guest" + + release := Release { + Credits: []*Credit{ + { Artist: Artist{ Name: artist1 }, Primary: true }, + { Artist: Artist{ Name: artist2 }, Primary: true }, + { Artist: Artist{ Name: artist3 }, Primary: false }, + { Artist: Artist{ Name: artist4 }, Primary: true }, + }, + } + + { + want := []string{ artist1, artist2, artist4 } + got := release.GetUniqueArtistNames(true) + if len(want) != len(got) { + t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want)) + } + for i := range got { + if want[i] != got[i] { + t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i]) + } + } + + want = []string{ artist1, artist2, artist3, artist4 } + got = release.GetUniqueArtistNames(false) + if len(want) != len(got) { + t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want)) + } + for i := range got { + if want[i] != got[i] { + t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i]) + } + } + } + + { + want := "ari melody, aridoodle & guest" + got := release.PrintArtists(true, true) + if want != got { + t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, guest" + got = release.PrintArtists(true, false) + if want != got { + t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, idk & guest" + got = release.PrintArtists(false, true) + if want != got { + t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, idk, guest" + got = release.PrintArtists(false, false) + if want != got { + t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got) + } + } +} diff --git a/model/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 @@ + + + Codeberg logo + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Codeberg logo + + + + Robert Martinez + + + + + Codeberg and the Codeberg Logo are trademarks of Codeberg e.V. + + + 2020-04-09 + + + Codeberg e.V. + + + codeberg.org + + + + + + + + + + + + + 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 @@ + + + + +Asset 2 + + + + + + + + + + + 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/script/config.js b/public/script/config.js index 1ab8b5a..402a74b 100644 --- a/public/script/config.js +++ b/public/script/config.js @@ -55,6 +55,7 @@ class Config { get crt() { return this._crt } set crt(/** @type boolean */ enabled) { this._crt = enabled; + this.save(); if (enabled) { document.body.classList.add("crt"); @@ -66,26 +67,24 @@ class Config { this.#listeners.get('crt').forEach(callback => { callback(this._crt); }) - - this.save(); } get cursor() { return this._cursor } set cursor(/** @type boolean */ value) { this._cursor = value; + this.save(); this.#listeners.get('cursor').forEach(callback => { callback(this._cursor); }) - this.save(); } get cursorFunMode() { return this._cursorFunMode } set cursorFunMode(/** @type boolean */ value) { this._cursorFunMode = value; + this.save(); this.#listeners.get('cursorFunMode').forEach(callback => { callback(this._cursorFunMode); }) - this.save(); } } diff --git a/public/script/cursor.js b/public/script/cursor.js index 188cdbb..ff87068 100644 --- a/public/script/cursor.js +++ b/public/script/cursor.js @@ -328,7 +328,7 @@ function cursorSetup() { switch (args[0]) { case 'id': { - myCursor.id = Number(args[1]); + myCursor.id = id; break; } case 'join': { @@ -381,12 +381,12 @@ function cursorDestroy() { document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keyup', handleKeyUp); + + ctx.clearRect(0, 0, canvas.width, canvas.height); cursors.clear(); myCursor = null; - cursorContainer.remove(); - console.log(`Cursor no longer tracking.`); running = false; } diff --git a/public/script/index.js b/public/script/index.js index 512ed8f..2197bd4 100644 --- a/public/script/index.js +++ b/public/script/index.js @@ -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); +}); diff --git a/public/script/main.js b/public/script/main.js index c1d5101..19eddc2 100644 --- a/public/script/main.js +++ b/public/script/main.js @@ -45,6 +45,23 @@ 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 != "") diff --git a/public/script/music.js b/public/script/music.js index 273ce2b..91f34ab 100644 --- a/public/script/music.js +++ b/public/script/music.js @@ -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); }); diff --git a/public/style/colours.css b/public/style/colours.css index 2bf607d..de63198 100644 --- a/public/style/colours.css +++ b/public/style/colours.css @@ -6,6 +6,7 @@ --secondary: #f8e05b; --tertiary: #f788fe; --links: #5eb2ff; + --live: #fd3737; } @media (prefers-color-scheme: light) { diff --git a/public/style/header.css b/public/style/header.css index 48971b5..f399d4d 100644 --- a/public/style/header.css +++ b/public/style/header.css @@ -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; } diff --git a/public/style/index.css b/public/style/index.css index f3cb761..b970c17 100644 --- a/public/style/index.css +++ b/public/style/index.css @@ -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; +} diff --git a/public/style/main.css b/public/style/main.css index 4e9e113..680ee2b 100644 --- a/public/style/main.css +++ b/public/style/main.css @@ -3,6 +3,7 @@ @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"; diff --git a/schema-migration/000-init.sql b/schema-migration/000-init.sql index f70dee6..90385ac 100644 --- a/schema-migration/000-init.sql +++ b/schema-migration/000-init.sql @@ -18,7 +18,9 @@ CREATE TABLE arimelody.account ( password TEXT NOT NULL, email TEXT, avatar_url TEXT, - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + fail_attempts INT NOT NULL DEFAULT 0, + locked BOOLEAN DEFAULT false ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); @@ -84,7 +86,7 @@ CREATE TABLE arimelody.musicrelease ( buylink text, copyright text, copyrightURL text, - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp ); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); diff --git a/schema-migration/003-fail-lock.sql b/schema-migration/003-fail-lock.sql new file mode 100644 index 0000000..32d19bf --- /dev/null +++ b/schema-migration/003-fail-lock.sql @@ -0,0 +1,3 @@ +-- it would be nice to prevent brute-forcing +ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0; +ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false; diff --git a/templates/templates.go b/templates/templates.go index 8d1a5ca..752c78d 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -1,8 +1,8 @@ package templates import ( - "html/template" - "path/filepath" + "html/template" + "path/filepath" ) var IndexTemplate = template.Must(template.ParseFiles( diff --git a/view/index.go b/view/index.go new file mode 100644 index 0000000..b6e3891 --- /dev/null +++ b/view/index.go @@ -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) + }) +} diff --git a/view/music.go b/view/music.go index 2d40ef0..8ed5279 100644 --- a/view/music.go +++ b/view/music.go @@ -1,13 +1,13 @@ package view import ( - "fmt" - "net/http" - "os" + "fmt" + "net/http" + "os" - "arimelody-web/controller" - "arimelody-web/model" - "arimelody-web/templates" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/templates" ) // HTTP HANDLER METHODS @@ -60,7 +60,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler { // only allow authorised users to view hidden releases privileged := false if !release.Visible { - session, err := controller.GetSessionFromRequest(app.DB, r) + session, err := controller.GetSessionFromRequest(app, r) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/view/static.go b/view/static.go new file mode 100644 index 0000000..52263a2 --- /dev/null +++ b/view/static.go @@ -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) + }) +} + diff --git a/views/footer.html b/views/footer.html index 217c4b5..eccf125 100644 --- a/views/footer.html +++ b/views/footer.html @@ -2,7 +2,12 @@
diff --git a/views/header.html b/views/header.html index 291b8ac..03a8384 100644 --- a/views/header.html +++ b/views/header.html @@ -23,9 +23,6 @@
  • music
  • -
  • - source -
  • blog diff --git a/views/index.html b/views/index.html index 636c369..df4d341 100644 --- a/views/index.html +++ b/views/index.html @@ -6,8 +6,8 @@ - - + + @@ -17,11 +17,28 @@ - + {{end}} {{define "content"}}
    + {{if .TwitchStatus}} +
    +
    +
    + livestream thumbnail + join in! +
    +
    +

    ari melody LIVE

    +

    streaming: {{.TwitchStatus.GameName}}

    +

    {{.TwitchStatus.Title}}

    +

    {{.TwitchStatus.ViewerCount}} viewers

    +
    +
    +
    + {{end}} +

    # 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 🛰️

    -