Compare commits

..

No commits in common. "main" and "feature/cursor" have entirely different histories.

71 changed files with 592 additions and 1617 deletions

View file

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

View file

@ -47,6 +47,3 @@ need to be up for this, making this ideal for some offline maintenance.
- `purgeInvites`: Deletes all available invite codes. - `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts. - `listAccounts`: Lists all active accounts.
- `deleteAccount <username>`: Deletes an account with a given `username`. - `deleteAccount <username>`: Deletes an account with a given `username`.
- `lockAccount <username>`: Locks the account under `username`.
- `unlockAccount <username>`: Unlocks the account under `username`.
- `logs`: Shows system logs.

View file

@ -1,17 +1,17 @@
package admin package admin
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func accountHandler(app *model.AppState) http.Handler { func accountHandler(app *model.AppState) http.Handler {

View file

@ -1,9 +1,9 @@
package admin package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/controller" "arimelody-web/controller"

View file

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

View file

@ -1,20 +1,20 @@
package admin package admin
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func Handler(app *model.AppState) http.Handler { func Handler(app *model.AppState) http.Handler {
@ -274,20 +274,11 @@ func loginHandler(app *model.AppState) http.Handler {
render() render()
return return
} }
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil { if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) 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, "Invalid username or password.")
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() render()
return return
} }
@ -308,8 +299,6 @@ func loginHandler(app *model.AppState) http.Handler {
render() render()
return return
} }
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound) http.Redirect(w, r, "/admin/totp", http.StatusFound)
return return
} }
@ -388,14 +377,8 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
return return
} }
if totpMethod == nil { if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { controller.SetSessionError(app.DB, session, "Invalid TOTP.")
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() render()
return return
} }
@ -483,7 +466,7 @@ func staticHandler() http.Handler {
func enforceSession(app *model.AppState, next http.Handler) http.Handler { func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r) session, err := controller.GetSessionFromRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -513,30 +496,3 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx)) 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
}

View file

@ -1,12 +1,12 @@
package admin package admin
import ( import (
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings" "strings"
) )
func logsHandler(app *model.AppState) http.Handler { func logsHandler(app *model.AppState) http.Handler {

View file

@ -1,12 +1,12 @@
package admin package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
func serveRelease(app *model.AppState) http.Handler { func serveRelease(app *model.AppState) http.Handler {

View file

@ -1,12 +1,12 @@
package admin package admin
import ( import (
"arimelody-web/log" "arimelody-web/log"
"fmt" "fmt"
"html/template" "html/template"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
) )
var indexTemplate = template.Must(template.ParseFiles( var indexTemplate = template.Must(template.ParseFiles(

View file

@ -1,9 +1,9 @@
package admin package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/controller" "arimelody-web/controller"

View file

@ -1,15 +1,15 @@
package api package api
import ( import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
) )
func Handler(app *model.AppState) http.Handler { func Handler(app *model.AppState) http.Handler {

View file

@ -1,66 +1,66 @@
package api package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
) )
func ServeAllArtists(app *model.AppState) http.Handler { func ServeAllArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artists = []*model.Artist{} var artists = []*model.Artist{}
artists, err := controller.GetAllArtists(app.DB) artists, err := controller.GetAllArtists(app.DB)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve all artists: %s\n", err) fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w) encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err = encoder.Encode(artists) err = encoder.Encode(artists)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type ( type (
creditJSON struct { creditJSON struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
ReleaseDate time.Time `json:"releaseDate" db:"release_date"` ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
Artwork string `json:"artwork"` Artwork string `json:"artwork"`
Role string `json:"role"` Role string `json:"role"`
Primary bool `json:"primary"` Primary bool `json:"primary"`
} }
artistJSON struct { artistJSON struct {
*model.Artist *model.Artist
Credits map[string]creditJSON `json:"credits"` Credits map[string]creditJSON `json:"credits"`
} }
) )
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
show_hidden_releases := session != nil && session.Account != nil show_hidden_releases := session != nil && session.Account != nil
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) 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) fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
var credits = map[string]creditJSON{} var credits = map[string]creditJSON{}
for _, credit := range dbCredits { 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 := json.NewEncoder(w)
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err = encoder.Encode(artistJSON{ err = encoder.Encode(artistJSON{
Artist: artist, Artist: artist,
Credits: credits, Credits: credits,
}) })
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func CreateArtist(app *model.AppState) http.Handler { func CreateArtist(app *model.AppState) http.Handler {

View file

@ -1,26 +1,26 @@
package api package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
) )
func ServeRelease(app *model.AppState, release *model.Release) http.Handler { 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 // only allow authorised users to view hidden releases
privileged := false privileged := false
if !release.Visible { if !release.Visible {
session, err := controller.GetSessionFromRequest(app, r) session, err := controller.GetSessionFromRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 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 := json.NewEncoder(w)
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err := encoder.Encode(response) err := encoder.Encode(response)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
}) })
} }
func ServeCatalog(app *model.AppState) http.Handler { func ServeCatalog(app *model.AppState) http.Handler {

View file

@ -1,13 +1,13 @@
package api package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
) )
type ( type (
@ -29,7 +29,7 @@ func ServeAllTracks(app *model.AppState) http.Handler {
dbTracks, err := controller.GetAllTracks(app.DB) dbTracks, err := controller.GetAllTracks(app.DB)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) 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 { 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 := json.NewEncoder(w)
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err = encoder.Encode(tracks) err = encoder.Encode(tracks)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err) 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 { 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) dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) 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{} releases := []string{}
@ -63,15 +63,15 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
releases = append(releases, release.ID) releases = append(releases, release.ID)
} }
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w) encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err = encoder.Encode(Track{ track, releases }) 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) 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 { func CreateTrack(app *model.AppState) http.Handler {

View file

@ -1,56 +1,56 @@
package api package api
import ( import (
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
"bufio" "bufio"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) { func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
split := strings.Split(*data, ";base64,") split := strings.Split(*data, ";base64,")
header := split[0] header := split[0]
imageData, err := base64.StdEncoding.DecodeString(split[1]) imageData, err := base64.StdEncoding.DecodeString(split[1])
ext, _ := strings.CutPrefix(header, "data:image/") ext, _ := strings.CutPrefix(header, "data:image/")
directory = filepath.Join(app.Config.DataDirectory, directory) directory = filepath.Join(app.Config.DataDirectory, directory)
switch ext { switch ext {
case "png": case "png":
case "jpg": case "jpg":
case "jpeg": case "jpeg":
default: default:
return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg") return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
} }
filename = fmt.Sprintf("%s.%s", filename, ext) filename = fmt.Sprintf("%s.%s", filename, ext)
// ensure directory exists // ensure directory exists
os.MkdirAll(directory, os.ModePerm) os.MkdirAll(directory, os.ModePerm)
imagePath := filepath.Join(directory, filename) imagePath := filepath.Join(directory, filename)
file, err := os.Create(imagePath) file, err := os.Create(imagePath)
if err != nil { if err != nil {
return "", err return "", err
} }
defer file.Close() defer file.Close()
// TODO: generate compressed versions of image (512x512?) // TODO: generate compressed versions of image (512x512?)
buffer := bufio.NewWriter(file) buffer := bufio.NewWriter(file)
_, err = buffer.Write(imageData) _, err = buffer.Write(imageData)
if err != nil { if err != nil {
return "", nil return "", nil
} }
if err := buffer.Flush(); err != nil { if err := buffer.Flush(); err != nil {
return "", nil return "", nil
} }
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext) app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
return filename, nil return filename, nil
} }

View file

@ -1,10 +1,10 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"strings" "strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) { func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
@ -110,26 +110,3 @@ func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err return err
} }
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
failAttempts := 0
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
if err != nil { return false, err }
locked := false
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
err = LockAccount(db, accountID)
if err != nil { return false, err }
locked = true
}
return locked, err
}
func LockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
return err
}
func UnlockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
return err
}

View file

@ -1,48 +1,48 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
// DATABASE // DATABASE
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) { 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) err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &artist, nil return &artist, nil
} }
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) { func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
var artists = []*model.Artist{} var artists = []*model.Artist{}
err := db.Select(&artists, "SELECT * FROM artist") err := db.Select(&artists, "SELECT * FROM artist")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return artists, nil return artists, nil
} }
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) { 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 "+ "SELECT * FROM artist "+
"WHERE id NOT IN "+ "WHERE id NOT IN "+
"(SELECT artist FROM musiccredit WHERE release=$1)", "(SELECT artist FROM musiccredit WHERE release=$1)",
releaseID) releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return artists, nil return artists, nil
} }
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) { 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 " } if !show_hidden { query += "AND visible=true " }
query += "ORDER BY release_date DESC" query += "ORDER BY release_date DESC"
rows, err := db.Query(query, artistID) rows, err := db.Query(query, artistID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
type NamePrimary struct { 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 { func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO artist (id, name, website, avatar) "+ "INSERT INTO artist (id, name, website, avatar) "+
"VALUES ($1, $2, $3, $4)", "VALUES ($1, $2, $3, $4)",
artist.ID, artist.ID,
artist.Name, artist.Name,
artist.Website, artist.Website,
artist.Avatar, artist.Avatar,
) )
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,14 +1,14 @@
package controller package controller
import ( import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"arimelody-web/model" "arimelody-web/model"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
) )
func GetConfig() model.Config { func GetConfig() model.Config {
@ -77,9 +77,5 @@ 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_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_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 return nil
} }

View file

@ -1,12 +1,12 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"math/rand" "math/rand"
"strings" "strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")

View file

@ -1,10 +1,10 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"net/http" "net/http"
"slices" "slices"
"strings" "strings"
) )
// Returns the request's original IP address, resolving the `x-forwarded-for` // Returns the request's original IP address, resolving the `x-forwarded-for`

View file

@ -1,14 +1,14 @@
package controller package controller
import ( import (
"fmt" "fmt"
"os" "os"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
const DB_VERSION int = 4 const DB_VERSION int = 3
func CheckDBVersionAndMigrate(db *sqlx.DB) { func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody") db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@ -45,10 +45,6 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "002-audit-logs") ApplyMigration(db, "002-audit-logs")
oldDBVersion = 3 oldDBVersion = 3
case 3:
ApplyMigration(db, "003-fail-lock")
oldDBVersion = 4
} }
} }

View file

@ -1,9 +1,13 @@
package controller package controller
import ( import (
"encoding/base64" "bytes"
"image" "encoding/base64"
"image/color" "errors"
"fmt"
"image"
"image/color"
"image/png"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
) )
@ -29,6 +33,69 @@ const (
HIGH 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) { func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 7 { for yi := range 7 {
for xi := range 7 { for xi := range 7 {

View file

@ -1,12 +1,12 @@
package controller package controller
import ( import (
"errors" "errors"
"fmt" "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) { func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {

View file

@ -1,22 +1,21 @@
package controller package controller
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"arimelody-web/log" "arimelody-web/model"
"arimelody-web/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
const TOKEN_LEN = 64 const TOKEN_LEN = 64
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) { func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie { if err != nil && err != http.ErrNoCookie {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err)) return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
@ -26,26 +25,14 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session
if sessionCookie != nil { if sessionCookie != nil {
// fetch existing session // fetch existing session
session, err = GetSession(app.DB, sessionCookie.Value) session, err = GetSession(db, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") { if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err)) return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
} }
if session != nil { if session != nil {
if session.UserAgent != r.UserAgent() { // TODO: consider running security checks here (i.e. user agent mismatches)
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
}
} }
} }
@ -188,7 +175,3 @@ func DeleteSession(db *sqlx.DB, token string) error {
return err return err
} }
func DeleteExpiredSessions(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM session WHERE expires_at<current_timestamp")
return err
}

View file

@ -1,20 +1,20 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"crypto/hmac" "crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha1" "crypto/sha1"
"encoding/base32" "encoding/base32"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
const TOTP_SECRET_LENGTH = 32 const TOTP_SECRET_LENGTH = 32

View file

@ -1,9 +1,9 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
// DATABASE // DATABASE
@ -13,19 +13,19 @@ func GetTrack(db *sqlx.DB, id string) (*model.Track, error) {
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1") stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
err := stmt.Get(&track, id) err := stmt.Get(&track, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &track, nil return &track, nil
} }
func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) { func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{} var tracks = []*model.Track{}
err := db.Select(&tracks, "SELECT * FROM musictrack") err := db.Select(&tracks, "SELECT * FROM musictrack")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return tracks, nil return tracks, nil
} }
@ -33,33 +33,33 @@ func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{} var tracks = []*model.Track{}
err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)") err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return tracks, nil return tracks, nil
} }
func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) { func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
var tracks = []*model.Track{} var tracks = []*model.Track{}
err := db.Select(&tracks, err := db.Select(&tracks,
"SELECT * FROM musictrack "+ "SELECT * FROM musictrack "+
"WHERE id NOT IN "+ "WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack WHERE release=$1)", "(SELECT track FROM musicreleasetrack WHERE release=$1)",
releaseID) releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return tracks, nil return tracks, nil
} }
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) { func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
var releases = []*model.Release{} var releases = []*model.Release{}
err := db.Select(&releases, err := db.Select(&releases,
"SELECT id,title,type,release_date,artwork,buylink "+ "SELECT id,title,type,release_date,artwork,buylink "+
"FROM musicrelease "+ "FROM musicrelease "+
"JOIN musicreleasetrack ON release=id "+ "JOIN musicreleasetrack ON release=id "+
@ -67,9 +67,9 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release,
"ORDER BY release_date", "ORDER BY release_date",
trackID, trackID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
type NamePrimary struct { type NamePrimary struct {
Name string `json:"name"` Name string `json:"name"`
@ -114,14 +114,14 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release,
func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{} var tracks = []*model.Track{}
err := db.Select(&tracks, err := db.Select(&tracks,
"SELECT id, title, description, lyrics, preview_url FROM musictrack "+ "SELECT id, title, description, lyrics, preview_url FROM musictrack "+
"WHERE id NOT IN "+ "WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack)", "(SELECT track FROM musicreleasetrack)",
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
return tracks, nil return tracks, nil
} }

View file

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

View file

@ -1,16 +1,16 @@
package cursor package cursor
import ( import (
"arimelody-web/model" "arimelody-web/model"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
type CursorClient struct { type CursorClient struct {
@ -88,13 +88,13 @@ func handleClient(client *CursorClient) {
client.Route = args[1] client.Route = args[1]
mutex.Lock() mutex.Lock()
for otherClientID, otherClient := range clients { for _, otherClient := range clients {
if otherClientID == client.ID || otherClient.Route != client.Route { continue } if otherClient.ID == client.ID { continue }
client.Send([]byte(fmt.Sprintf("join:%d", otherClientID))) if otherClient.Route != client.Route { continue }
client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClientID, otherClient.X, otherClient.Y))) client.Send([]byte(fmt.Sprintf("join:%d", otherClient.ID)))
client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClient.ID, otherClient.X, otherClient.Y)))
} }
mutex.Unlock() mutex.Unlock()
broadcast <- CursorMessage{ broadcast <- CursorMessage{
[]byte(fmt.Sprintf("join:%d", client.ID)), []byte(fmt.Sprintf("join:%d", client.ID)),
client.Route, client.Route,

View file

@ -1,13 +1,13 @@
package discord package discord
import ( import (
"arimelody-web/model" "arimelody-web/model"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
) )
const API_ENDPOINT = "https://discord.com/api/v10" const API_ENDPOINT = "https://discord.com/api/v10"

View file

@ -1,11 +1,11 @@
package log package log
import ( import (
"fmt" "fmt"
"os" "os"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type ( type (

175
main.go
View file

@ -1,32 +1,33 @@
package main package main
import ( import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
stdLog "log" stdLog "log"
"math" "math"
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"arimelody-web/admin" "arimelody-web/admin"
"arimelody-web/api" "arimelody-web/api"
"arimelody-web/colour" "arimelody-web/colour"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/cursor" "arimelody-web/cursor"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/view" "arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// used for database migrations // used for database migrations
@ -39,7 +40,6 @@ func main() {
app := model.AppState{ app := model.AppState{
Config: controller.GetConfig(), Config: controller.GetConfig(),
Twitch: nil,
} }
// initialise database connection // initialise database connection
@ -223,7 +223,7 @@ func main() {
code := controller.GenerateTOTP(totp.Secret, 0) code := controller.GenerateTOTP(totp.Secret, 0)
fmt.Printf("%s\n", code) fmt.Printf("%s\n", code)
return return
case "cleanTOTP": case "cleanTOTP":
err := controller.DeleteUnconfirmedTOTPs(app.DB) err := controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil { if err != nil {
@ -276,13 +276,11 @@ func main() {
"User: %s\n" + "User: %s\n" +
"\tID: %s\n" + "\tID: %s\n" +
"\tEmail: %s\n" + "\tEmail: %s\n" +
"\tCreated: %s\n" + "\tCreated: %s\n",
"\tLocked: %t\n",
account.Username, account.Username,
account.ID, account.ID,
email, email,
account.CreatedAt, account.CreatedAt,
account.Locked,
) )
} }
return return
@ -346,7 +344,7 @@ func main() {
if !strings.HasPrefix(res, "y") { if !strings.HasPrefix(res, "y") {
return return
} }
err = controller.DeleteAccount(app.DB, account.ID) err = controller.DeleteAccount(app.DB, account.ID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
@ -357,64 +355,6 @@ func main() {
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return return
case "lockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.LockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
return
case "unlockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.UnlockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
return
case "logs": case "logs":
// TODO: add log search parameters // TODO: add log search parameters
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0) logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
@ -449,10 +389,7 @@ func main() {
"createInvite:\n\tCreates an invite code to register new accounts.\n" + "createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" +
"listAccounts:\n\tLists all active accounts.\n", "listAccounts:\n\tLists all active accounts.\n",
"deleteAccount <username>:\n\tDeletes the account under `username`.\n", "deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
"lockAccount <username>:\n\tLocks the account under `username`.\n",
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
"logs:\n\tShows system logs.\n",
) )
return return
} }
@ -460,13 +397,6 @@ func main() {
// handle DB migrations // handle DB migrations
controller.CheckDBVersionAndMigrate(app.DB) 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 // initial invite code
accountsCount := 0 accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
@ -487,13 +417,6 @@ func main() {
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) 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 // delete expired invites
err = controller.DeleteExpiredInvites(app.DB) err = controller.DeleteExpiredInvites(app.DB)
if err != nil { if err != nil {
@ -525,13 +448,49 @@ func createServeMux(app *model.AppState) *http.ServeMux {
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app)) mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", view.IndexHandler(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)
}))
return mux 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{ var PoweredByStrings = []string{
"nerd rage", "nerd rage",
"estrogen", "estrogen",

View file

@ -1,23 +1,20 @@
package model package model
import ( import (
"database/sql" "database/sql"
"time" "time"
) )
const COOKIE_TOKEN string = "AM_SESSION" const COOKIE_TOKEN string = "AM_SESSION"
const MAX_LOGIN_FAIL_ATTEMPTS int = 3
type ( type (
Account struct { Account struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"` Password string `json:"password" db:"password"`
Email sql.NullString `json:"email" db:"email"` Email sql.NullString `json:"email" db:"email"`
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"` 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"` Privileges []AccountPrivilege `json:"privileges"`
} }

View file

@ -1,7 +1,7 @@
package model package model
import ( import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"arimelody-web/log" "arimelody-web/log"
) )
@ -21,12 +21,6 @@ type (
Secret string `toml:"secret"` Secret string `toml:"secret"`
} }
TwitchConfig struct {
Broadcaster string `toml:"broadcaster"`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
Config struct { Config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Host string `toml:"host"` Host string `toml:"host"`
@ -34,14 +28,12 @@ type (
DataDirectory string `toml:"data_dir"` DataDirectory string `toml:"data_dir"`
TrustedProxies []string `toml:"trusted_proxies"` TrustedProxies []string `toml:"trusted_proxies"`
DB DBConfig `toml:"db"` DB DBConfig `toml:"db"`
Discord *DiscordConfig `toml:"discord"` Discord DiscordConfig `toml:"discord"`
Twitch *TwitchConfig `toml:"twitch"`
} }
AppState struct { AppState struct {
DB *sqlx.DB DB *sqlx.DB
Config Config Config Config
Log log.Logger Log log.Logger
Twitch *TwitchState
} }
) )

View file

@ -1,17 +1,21 @@
package model package model
type ( type (
Artist struct { Artist struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Website string `json:"website"` Website string `json:"website"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
} }
) )
func (artist Artist) GetAvatar() string { func (artist Artist) GetWebsite() string {
if artist.Avatar == "" { return artist.Website
return "/img/default-avatar.png" }
}
return artist.Avatar func (artist Artist) GetAvatar() string {
if artist.Avatar == "" {
return "/img/default-avatar.png"
}
return artist.Avatar
} }

View file

@ -1,23 +0,0 @@
package model
import (
"testing"
)
func Test_Artist_GetAvatar(t *testing.T) {
want := "testavatar.png"
artist := Artist{ Avatar: want }
got := artist.GetAvatar()
if want != got {
t.Errorf(`correct value not returned when avatar is populated (want "%s", got "%s")`, want, got)
}
artist = Artist{}
want = "/img/default-avatar.png"
got = artist.GetAvatar()
if want != got {
t.Errorf(`default value not returned when avatar is empty (want "%s", got "%s")`, want, got)
}
}

View file

@ -1,16 +1,16 @@
package model package model
import ( import (
"regexp" "regexp"
"strings" "strings"
) )
type Link struct { type Link struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
} }
func (link Link) NormaliseName() string { func (link Link) NormaliseName() string {
rgx := regexp.MustCompile(`[^a-z0-9\-]`) rgx := regexp.MustCompile(`[^a-z0-9]`)
return rgx.ReplaceAllString(strings.ToLower(link.Name), "") return strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
} }

View file

@ -1,23 +0,0 @@
package model
import (
"testing"
)
func Test_Link_NormaliseName(t *testing.T) {
link := Link{
Name: "!c@o#o$l%-^a&w*e(s)o_m=e+-[l{i]n}k-0123456789ABCDEF",
}
want := "cool-awesome-link-0123456789abcdef"
got := link.NormaliseName()
if want != got {
t.Errorf(`name with invalid characters not properly formatted (want "%s", got "%s")`, want, got)
}
link.Name = want
got = link.NormaliseName()
if want != got {
t.Errorf(`valid name mangled by formatter (want "%s", got "%s")`, want, got)
}
}

View file

@ -1,9 +1,9 @@
package model package model
import ( import (
"html/template" "html/template"
"strings" "strings"
"time" "time"
) )
type ( type (
@ -50,6 +50,10 @@ func (release Release) PrintReleaseDate() string {
return release.ReleaseDate.Format("02 January 2006") return release.ReleaseDate.Format("02 January 2006")
} }
func (release Release) GetReleaseYear() int {
return release.ReleaseDate.Year()
}
func (release Release) GetArtwork() string { func (release Release) GetArtwork() string {
if release.Artwork == "" { if release.Artwork == "" {
return "/img/default-cover-art.png" return "/img/default-cover-art.png"
@ -73,23 +77,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string {
names = append(names, credit.Artist.Name) names = append(names, credit.Artist.Name)
} }
return names return names
} }
func (release Release) PrintArtists(only_primary bool, ampersand bool) string { func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
names := release.GetUniqueArtistNames(only_primary) names := release.GetUniqueArtistNames(only_primary)
if len(names) == 0 { if len(names) == 0 {
return "Unknown Artist" return "Unknown Artist"
} else if len(names) == 1 { } else if len(names) == 1 {
return names[0] return names[0]
} }
if ampersand { if ampersand {
res := strings.Join(names[:len(names)-1], ", ") res := strings.Join(names[:len(names)-1], ", ")
res += " & " + names[len(names)-1] res += " & " + names[len(names)-1]
return res return res
} else { } else {
return strings.Join(names[:], ", ") return strings.Join(names[:], ", ")
} }
} }

View file

@ -1,157 +0,0 @@
package model
import (
"testing"
"time"
)
func Test_Release_DescriptionHTML(t *testing.T) {
release := Release{
Description: "this is\na test\n<strong>description!</strong>",
}
// descriptions are set by privileged users,
// so we'll allow HTML injection here
want := "this is<br>a test<br><strong>description!</strong>"
got := release.GetDescriptionHTML()
if want != string(got) {
t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got)
}
}
func Test_Release_ReleaseDate(t *testing.T) {
release := Release{
ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC),
}
want := "2025-07-26T16:00"
got := release.TextReleaseDate()
if want != got {
t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got)
}
want = "26 July 2025"
got = release.PrintReleaseDate()
if want != got {
t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got)
}
}
func Test_Release_Artwork(t *testing.T) {
want := "testartwork.png"
release := Release{ Artwork: want }
got := release.GetArtwork()
if want != got {
t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got)
}
release = Release{}
want = "/img/default-cover-art.png"
got = release.GetArtwork()
if want != got {
t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got)
}
}
func Test_Release_IsSingle(t *testing.T) {
release := Release{
Tracks: []*Track{},
}
if release.IsSingle() {
t.Errorf("IsSingle() == true when no tracks are present")
}
release.Tracks = append(release.Tracks, &Track{})
if !release.IsSingle() {
t.Errorf("IsSingle() == false when one track is present")
}
release.Tracks = append(release.Tracks, &Track{})
if release.IsSingle() {
t.Errorf("IsSingle() == true when >1 tracks are present")
}
}
func Test_Release_IsReleased(t *testing.T) {
release := Release {
ReleaseDate: time.Now(),
}
if !release.IsReleased() {
t.Errorf("IsRelease() == false when release date in the past")
}
release.ReleaseDate = time.Now().Add(time.Hour)
if release.IsReleased() {
t.Errorf("IsRelease() == true when release date in the future")
}
}
func Test_Release_PrintArtists(t *testing.T) {
artist1 := "ari melody"
artist2 := "aridoodle"
artist3 := "idk"
artist4 := "guest"
release := Release {
Credits: []*Credit{
{ Artist: Artist{ Name: artist1 }, Primary: true },
{ Artist: Artist{ Name: artist2 }, Primary: true },
{ Artist: Artist{ Name: artist3 }, Primary: false },
{ Artist: Artist{ Name: artist4 }, Primary: true },
},
}
{
want := []string{ artist1, artist2, artist4 }
got := release.GetUniqueArtistNames(true)
if len(want) != len(got) {
t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want))
}
for i := range got {
if want[i] != got[i] {
t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i])
}
}
want = []string{ artist1, artist2, artist3, artist4 }
got = release.GetUniqueArtistNames(false)
if len(want) != len(got) {
t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want))
}
for i := range got {
if want[i] != got[i] {
t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i])
}
}
}
{
want := "ari melody, aridoodle & guest"
got := release.PrintArtists(true, true)
if want != got {
t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got)
}
want = "ari melody, aridoodle, guest"
got = release.PrintArtists(true, false)
if want != got {
t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got)
}
want = "ari melody, aridoodle, idk & guest"
got = release.PrintArtists(false, true)
if want != got {
t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got)
}
want = "ari melody, aridoodle, idk, guest"
got = release.PrintArtists(false, false)
if want != got {
t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got)
}
}
}

View file

@ -1,8 +1,8 @@
package model package model
import ( import (
"database/sql" "database/sql"
"time" "time"
) )
type Session struct { type Session struct {

View file

@ -1,7 +1,7 @@
package model package model
import ( import (
"time" "time"
) )
type TOTP struct { type TOTP struct {

View file

@ -1,20 +1,20 @@
package model package model
import ( import (
"html/template" "html/template"
"strings" "strings"
) )
type ( type (
Track struct { Track struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Lyrics string `json:"lyrics" db:"lyrics"` Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"` PreviewURL string `json:"previewURL" db:"preview_url"`
Number int Number int
} }
) )
func (track Track) GetDescriptionHTML() template.HTML { func (track Track) GetDescriptionHTML() template.HTML {

View file

@ -1,43 +0,0 @@
package model
import (
"testing"
)
func Test_Track_DescriptionHTML(t *testing.T) {
track := Track{
Description: "this is\na test\n<strong>description!</strong>",
}
// descriptions are set by privileged users,
// so we'll allow HTML injection here
want := "this is<br>a test<br><strong>description!</strong>"
got := track.GetDescriptionHTML()
if want != string(got) {
t.Errorf(`track description incorrectly formatted (want "%s", got "%s")`, want, got)
}
}
func Test_Track_LyricsHTML(t *testing.T) {
track := Track{
Lyrics: "these are\ntest\n<strong>lyrics!</strong>",
}
// lyrics are set by privileged users,
// so we'll allow HTML injection here
want := "these are<br>test<br><strong>lyrics!</strong>"
got := track.GetLyricsHTML()
if want != string(got) {
t.Errorf(`track lyrics incorrectly formatted (want "%s", got "%s")`, want, got)
}
}
func Test_Track_Add(t *testing.T) {
track := Track{}
want := 4
got := track.Add(2, 2)
if want != got {
t.Errorf(`somehow, we screwed up addition. (want %d, got %d)`, want, got)
}
}

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

Before

Width:  |  Height:  |  Size: 5.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 890 B

View file

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -55,7 +55,6 @@ class Config {
get crt() { return this._crt } get crt() { return this._crt }
set crt(/** @type boolean */ enabled) { set crt(/** @type boolean */ enabled) {
this._crt = enabled; this._crt = enabled;
this.save();
if (enabled) { if (enabled) {
document.body.classList.add("crt"); document.body.classList.add("crt");
@ -67,24 +66,26 @@ class Config {
this.#listeners.get('crt').forEach(callback => { this.#listeners.get('crt').forEach(callback => {
callback(this._crt); callback(this._crt);
}) })
this.save();
} }
get cursor() { return this._cursor } get cursor() { return this._cursor }
set cursor(/** @type boolean */ value) { set cursor(/** @type boolean */ value) {
this._cursor = value; this._cursor = value;
this.save();
this.#listeners.get('cursor').forEach(callback => { this.#listeners.get('cursor').forEach(callback => {
callback(this._cursor); callback(this._cursor);
}) })
this.save();
} }
get cursorFunMode() { return this._cursorFunMode } get cursorFunMode() { return this._cursorFunMode }
set cursorFunMode(/** @type boolean */ value) { set cursorFunMode(/** @type boolean */ value) {
this._cursorFunMode = value; this._cursorFunMode = value;
this.save();
this.#listeners.get('cursorFunMode').forEach(callback => { this.#listeners.get('cursorFunMode').forEach(callback => {
callback(this._cursorFunMode); callback(this._cursorFunMode);
}) })
this.save();
} }
} }

View file

@ -328,7 +328,7 @@ function cursorSetup() {
switch (args[0]) { switch (args[0]) {
case 'id': { case 'id': {
myCursor.id = id; myCursor.id = Number(args[1]);
break; break;
} }
case 'join': { case 'join': {
@ -381,12 +381,12 @@ function cursorDestroy() {
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('keypress', handleKeyPress); document.removeEventListener('keypress', handleKeyPress);
document.removeEventListener('keyup', handleKeyUp); document.removeEventListener('keyup', handleKeyUp);
ctx.clearRect(0, 0, canvas.width, canvas.height);
cursors.clear(); cursors.clear();
myCursor = null; myCursor = null;
cursorContainer.remove();
console.log(`Cursor no longer tracking.`); console.log(`Cursor no longer tracking.`);
running = false; running = false;
} }

View file

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

View file

@ -45,23 +45,6 @@ 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.addEventListener("DOMContentLoaded", () => {
[...document.querySelectorAll(".typeout")] [...document.querySelectorAll(".typeout")]
.filter((e) => e.innerText != "") .filter((e) => e.innerText != "")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,9 +18,7 @@ CREATE TABLE arimelody.account (
password TEXT NOT NULL, password TEXT NOT NULL,
email TEXT, email TEXT,
avatar_url 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); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
@ -86,7 +84,7 @@ CREATE TABLE arimelody.musicrelease (
buylink text, buylink text,
copyright text, copyright text,
copyrightURL 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); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);

View file

@ -1,3 +0,0 @@
-- it would be nice to prevent brute-forcing
ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0;
ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false;

View file

@ -1,8 +1,8 @@
package templates package templates
import ( import (
"html/template" "html/template"
"path/filepath" "path/filepath"
) )
var IndexTemplate = template.Must(template.ParseFiles( var IndexTemplate = template.Must(template.ParseFiles(

View file

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

View file

@ -1,13 +1,13 @@
package view package view
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/templates" "arimelody-web/templates"
) )
// HTTP HANDLER METHODS // 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 // only allow authorised users to view hidden releases
privileged := false privileged := false
if !release.Visible { if !release.Visible {
session, err := controller.GetSessionFromRequest(app, r) session, err := controller.GetSessionFromRequest(app.DB, r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

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

View file

@ -2,12 +2,7 @@
<footer> <footer>
<div id="footer"> <div id="footer">
<small> <small><em>*made with <span aria-label="love"></span> by ari, 2025*</em></small>
<em>
*made with <span aria-label="love"></span> by ari, 2025*
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
</em>
</small>
</div> </div>
</footer> </footer>

View file

@ -23,6 +23,9 @@
<li> <li>
<a href="/music" preload="mouseover">music</a> <a href="/music" preload="mouseover">music</a>
</li> </li>
<li>
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
</li>
<li> <li>
<!-- coming later! --> <!-- coming later! -->
<span title="coming later!">blog</span> <span title="coming later!">blog</span>

View file

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

View file

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