Compare commits

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

30 commits

Author SHA1 Message Date
1eed8f0e84
day 1 patch 2025-08-29 16:30:25 +01:00
6bd0b5ec0e
update pgp key 2025-08-27 18:42:30 +01:00
d13cfc74ad
complete arimelody.space migration! 🎉 2025-08-22 01:06:37 +01:00
c82709084b
add quick security check to requests 2025-08-20 12:41:55 +01:00
5a330ad7fa
fix some opengraph 2025-08-19 15:28:52 +01:00
c63a090569
fix HTTPLog panic with no User-Agent 2025-08-16 22:35:49 +01:00
c49084afb0
replace straggler arimelody.me mentions with .space
email notwithstanding
2025-08-14 01:07:53 +01:00
ab07554716
update fediverse rel 2025-08-13 23:26:23 +01:00
02905b9c47
Merge remote-tracking branch 'origin/dev' into dev 2025-08-01 00:33:19 +01:00
90ac0487e3
update web buttons 2025-08-01 00:29:47 +01:00
1896633fd1
clear expired sessions on server start
why wasn't i doing this before?? 😭
2025-07-18 23:38:01 +01:00
05ab207a1f
live banner: fix horizontal alignment 2025-07-05 18:53:57 +01:00
7a354cddf5
update some more links! 2025-06-22 18:05:21 +01:00
375ae84ae3
update some arimelody.me references to .space 2025-06-22 18:00:08 +01:00
581273370d
improvements to LIVE tracker 2025-06-17 02:03:53 +01:00
9274796729
early implementation of ari melody LIVE tracker 2025-06-17 01:15:08 +01:00
f7b3faf8e8
move source link from header to footer 2025-06-16 22:41:54 +01:00
69e2e22e47
homepage rework for socials and projects 2025-06-16 20:32:46 +01:00
fe84a59326
add thermia web button 2025-06-15 02:51:23 +01:00
da1cd0204e
update socials and projects 2025-05-23 12:23:05 +01:00
30c4252e40
hamburger dropdown: match theme background colour 2025-05-21 15:49:21 +01:00
35e149e186
oops 2025-05-05 17:55:42 +01:00
76cf1bb0d5
log IP address for account locks 🧌 2025-04-30 18:21:47 +01:00
37fa1f4fa8
and she never dealt with indentation issues ever again 2025-04-29 23:42:08 +01:00
23a02617f9
fix indentation (tabs to 4 spaces) (oops) 2025-04-29 23:25:32 +01:00
fe4a788898
update cli utility docs 2025-04-29 23:22:48 +01:00
562ed2e015
session validation/invalidation 2025-04-29 16:31:39 +01:00
1c0e541c89
lock accounts after enough failed login attempts 2025-04-29 11:32:48 +01:00
5cc9a1ca76
clear cursor display when shutting down 2025-03-31 13:44:29 +01:00
ed86aff2a2
fix config not saving with broken listeners, fix cursor ReferenceError 2025-03-31 13:36:02 +01:00
74 changed files with 1524 additions and 676 deletions

7
.editorconfig Normal file
View file

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

View file

@ -1,17 +1,12 @@
# arimelody.me
# ari melody website
home to your local SPACEGIRL! 💫
---
built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static)
branch, this powerful, server-side rendered version comes complete with live
updates, powered by a new database and handy admin panel!
the admin panel currently facilitates live updating of my music discography,
though i plan to expand it towards art portfolio and blog posts in the future.
if all goes well, i'd like to later separate these components into their own
library for others to use in their own sites. exciting stuff!
a slightly-overcomplicated webserver built to show off everything i've worked
on, and then some! this server comes complete with twitch live status tracking,
a portfolio database, and a full-fledged admin CMS panel to manage it all!
## build
@ -47,3 +42,6 @@ need to be up for this, making this ideal for some offline maintenance.
- `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts.
- `deleteAccount <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
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/bcrypt"
)
func accountHandler(app *model.AppState) http.Handler {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@
<nav>
<img src="/img/favicon.png" alt="" class="icon">
<div class="nav-item">
<a href="/">arimelody.me</a>
<a href="/">ari melody</a>
</div>
<div class="nav-item">
<a href="/admin">home</a>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
package controller
import (
"errors"
"fmt"
"os"
"strconv"
"errors"
"fmt"
"os"
"strconv"
"arimelody-web/model"
"arimelody-web/model"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2"
)
func GetConfig() model.Config {
@ -18,7 +18,7 @@ func GetConfig() model.Config {
}
config := model.Config{
BaseUrl: "https://arimelody.me",
BaseUrl: "https://arimelody.space",
Host: "0.0.0.0",
Port: 8080,
TrustedProxies: []string{ "127.0.0.1" },
@ -77,5 +77,9 @@ func handleConfigOverrides(config *model.Config) error {
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
return nil
}

View file

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

View file

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

View file

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

View file

@ -1,13 +1,9 @@
package controller
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"encoding/base64"
"image"
"image/color"
"github.com/skip2/go-qrcode"
)
@ -33,69 +29,6 @@ const (
HIGH
)
func noDepsGenerateQRCode() (string, error) {
version := 1
size := 0
size = 21 + version * 4
if version > 10 {
return "", errors.New(fmt.Sprintf("QR version %d not supported", version))
}
img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2))
// fill white
for y := range size + margin * 2 {
for x := range size + margin * 2 {
img.Set(x, y, color.White)
}
}
// draw alignment squares
drawLargeAlignmentSquare(margin, margin, img)
drawLargeAlignmentSquare(margin, margin + size - 7, img)
drawLargeAlignmentSquare(margin + size - 7, margin, img)
drawSmallAlignmentSquare(size - 5, size - 5, img)
/*
if version > 4 {
space := version * 3 - 2
end := size / space
for y := range size / space + 1 {
for x := range size / space + 1 {
if x == 0 && y == 0 { continue }
if x == 0 && y == end { continue }
if x == end && y == 0 { continue }
if x == end && y == end { continue }
drawSmallAlignmentSquare(
x * space + margin + 4,
y * space + margin + 4,
img,
)
}
}
}
*/
// draw timing bits
for i := margin + 6; i < size - 4; i++ {
if (i % 2 == 0) {
img.Set(i, margin + 6, color.Black)
img.Set(margin + 6, i, color.Black)
}
}
img.Set(margin + 8, size - 4, color.Black)
var imgBuf bytes.Buffer
err := png.Encode(&imgBuf, img)
if err != nil {
return "", err
}
base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes())
return "data:image/png;base64," + base64Img, nil
}
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 7 {
for xi := range 7 {

View file

@ -1,12 +1,12 @@
package controller
import (
"errors"
"fmt"
"errors"
"fmt"
"arimelody-web/model"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {

View file

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

View file

@ -1,20 +1,20 @@
package controller
import (
"arimelody-web/model"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"os"
"strings"
"time"
"arimelody-web/model"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"os"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const TOTP_SECRET_LENGTH = 32
@ -58,12 +58,12 @@ func GenerateTOTPURI(username string, secret string) string {
url := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
Path: url.QueryEscape("arimelody.space") + ":" + url.QueryEscape(username),
}
query := url.Query()
query.Set("secret", secret)
query.Set("issuer", "arimelody.me")
query.Set("issuer", "arimelody.space")
// query.Set("algorithm", "SHA1")
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))

View file

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

94
controller/twitch.go Normal file
View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
services:
web:
image: docker.arimelody.me/arimelody.me:latest
image: docker.arimelody.space/arimelody-web:latest
build: .
ports:
- 8080:8080

View file

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

217
main.go
View file

@ -1,45 +1,46 @@
package main
import (
"bufio"
"errors"
"fmt"
stdLog "log"
"math"
"math/rand"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"bufio"
"errors"
"fmt"
stdLog "log"
"math"
"math/rand"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/colour"
"arimelody-web/controller"
"arimelody-web/cursor"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/templates"
"arimelody-web/view"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/colour"
"arimelody-web/controller"
"arimelody-web/cursor"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080
const HRT_DATE int64 = 1756478697
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
app := model.AppState{
Config: controller.GetConfig(),
Twitch: nil,
}
// initialise database connection
@ -223,7 +224,7 @@ func main() {
code := controller.GenerateTOTP(totp.Secret, 0)
fmt.Printf("%s\n", code)
return
case "cleanTOTP":
err := controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
@ -276,11 +277,13 @@ func main() {
"User: %s\n" +
"\tID: %s\n" +
"\tEmail: %s\n" +
"\tCreated: %s\n",
"\tCreated: %s\n" +
"\tLocked: %t\n",
account.Username,
account.ID,
email,
account.CreatedAt,
account.Locked,
)
}
return
@ -344,7 +347,7 @@ func main() {
if !strings.HasPrefix(res, "y") {
return
}
err = controller.DeleteAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
@ -355,6 +358,64 @@ func main() {
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
case "lockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.LockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
return
case "unlockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.UnlockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
return
case "logs":
// TODO: add log search parameters
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
@ -389,7 +450,10 @@ func main() {
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" +
"listAccounts:\n\tLists all active accounts.\n",
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
"deleteAccount <username>:\n\tDeletes the account under `username`.\n",
"lockAccount <username>:\n\tLocks the account under `username`.\n",
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
"logs:\n\tShows system logs.\n",
)
return
}
@ -397,6 +461,13 @@ func main() {
// handle DB migrations
controller.CheckDBVersionAndMigrate(app.DB)
if app.Config.Twitch != nil {
err = controller.TwitchSetup(&app)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
}
}
// initial invite code
accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
@ -417,6 +488,13 @@ func main() {
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
}
// delete expired sessions
err = controller.DeleteExpiredSessions(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
os.Exit(1)
}
// delete expired invites
err = controller.DeleteExpiredInvites(app.DB)
if err != nil {
@ -438,7 +516,7 @@ func main() {
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
stdLog.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
HTTPLog(DefaultHeaders(mux)),
CheckRequest(&app, HTTPLog(DefaultHeaders(mux))),
))
}
@ -448,49 +526,13 @@ func createServeMux(app *model.AppState) *http.ServeMux {
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.IndexTemplate.Execute(w, nil)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
staticHandler("public").ServeHTTP(w, r)
}))
mux.Handle("/", view.IndexHandler(app))
return mux
}
func staticHandler(directory string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
})
}
var PoweredByStrings = []string{
"nerd rage",
"estrogen",
@ -521,9 +563,40 @@ var PoweredByStrings = []string{
"30 billion dollars in VC funding",
}
func CheckRequest(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requests with empty user agents are considered suspicious.
// every browser supplies them; hell, even curl supplies them.
// i only ever see null user-agents paired with malicious requests,
// so i'm canning them altogether.
if len(r.Header.Get("User-Agent")) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
// same with .php and awkward double-slash requests.
// obviously these don't affect me, but these tend to be lazy intrusion
// attempts. if that's what you're about, i don't want you on my site.
if strings.HasPrefix(r.URL.Path, "//") ||
strings.HasSuffix(r.URL.Path, ".php") ||
strings.HasSuffix(r.URL.Path, ".php7") {
http.NotFound(w, r)
fmt.Fprintf(
os.Stderr,
"WARN: Suspicious activity blocked: {\"path\":\"%s\",\"address\":\"%s\"}\n",
r.URL.Path,
r.RemoteAddr,
)
return
}
next.ServeHTTP(w, r)
})
}
func DefaultHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "arimelody.me")
w.Header().Add("Server", "ari melody webbed site")
w.Header().Add("Do-Not-Stab", "1")
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
w.Header().Add("X-Hacker", "spare me please")
@ -533,6 +606,10 @@ func DefaultHeaders(next http.Handler) http.Handler {
"X-Powered-By",
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
)
w.Header().Add(
"X-Days-Since-HRT",
fmt.Sprint(math.Round(time.Since(time.Unix(HRT_DATE, 0)).Hours() / 24)),
)
next.ServeHTTP(w, r)
})
}
@ -585,6 +662,6 @@ func HTTPLog(next http.Handler) http.Handler {
lrw.Status,
colour.Reset,
elapsed,
r.Header["User-Agent"][0])
r.Header.Get("User-Agent"))
})
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
package model
import (
"html/template"
"strings"
"time"
"html/template"
"strings"
"time"
)
type (
@ -73,23 +73,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string {
names = append(names, credit.Artist.Name)
}
return names
return names
}
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
names := release.GetUniqueArtistNames(only_primary)
if len(names) == 0 {
return "Unknown Artist"
} else if len(names) == 1 {
return names[0]
}
if len(names) == 0 {
return "Unknown Artist"
} else if len(names) == 1 {
return names[0]
}
if ampersand {
res := strings.Join(names[:len(names)-1], ", ")
res += " & " + names[len(names)-1]
return res
} else {
return strings.Join(names[:], ", ")
}
if ampersand {
res := strings.Join(names[:len(names)-1], ", ")
res += " & " + names[len(names)-1]
return res
} else {
return strings.Join(names[:], ", ")
}
}

View file

@ -1,8 +1,8 @@
package model
import (
"testing"
"time"
"testing"
"time"
)
func Test_Release_DescriptionHTML(t *testing.T) {

View file

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

View file

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

View file

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

43
model/twitch.go Normal file
View file

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

View file

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

After

Width:  |  Height:  |  Size: 890 B

View file

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -1,32 +1,32 @@
<!--
pride flag - copyright (c) 2024 ari melody
this code is provided AS-IS, WITHOUT ANY WARRANTY, to be
freely redistributed and/or modified as you please, however
retaining this license in any redistribution.
please use this flag to link to an LGBTQI+-supporting page
of your choosing!
web: https://arimelody.me
source: https://git.arimelody.me/ari/prideflag
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>
<!--
pride flag - copyright (c) 2024 ari melody
this code is provided AS-IS, WITHOUT ANY WARRANTY, to be
freely redistributed and/or modified as you please, however
retaining this license in any redistribution.
please use this flag to link to an LGBTQI+-supporting page
of your choosing!
web: https://arimelody.space
source: https://forge.arimelody.space/ari/prideflag
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -1,26 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF
CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB
iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ
+oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI
kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC
BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF
ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P
f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL
d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq
VgUJBaOeTACBdiAEGRYKAB0WIQQlu5dWmBR/P3ZxngxgtfA4bj3bfgUCZ7UmigAK
CRBgtfA4bj3bfux+AP4y5ydrjnGBMX7GuB2nh55SRdscSiXsZ66ntnjXyQcbWgEA
pDuu7FqXzXcnluuZxNFDT740Rnzs60tTeplDqGGWcAQJEM+ZgpySZ4GIc0kA/iSw
Nw+r3FC75omwrPpJF13B5fq93FweFx+oSaES6qzkAQDvgCK77qKKbvCju0g8zSsK
EZnv6xR4uvtGdVkvLpBdC7gzBGe1JpkWCSsGAQQB2kcPAQEHQGnU4lXFLchhKYkC
PshP+jvuRsNoedaDOK2p4dkQC8JuiH4EGBYKACYCGyAWIQTujeuNYocuegkeKt/P
mYKckmeBiAUCZ7UqXgUJBaOeRQAKCRDPmYKckmeBiL9KAQCJZIBhuSsoYa61I0XZ
cKzGZbB0h9pD6eg1VRswNIgHtQEAwu9Hgs1rs9cySvKbO7WgK6Qh6EfrvGgGOXCO
m3wVsg24OARntSo5EgorBgEEAZdVAQUBAQdA+/k586W1OHxndzDJNpbd+wqjyjr0
D5IXxfDs00advB0DAQgHiH4EGBYKACYWIQTujeuNYocuegkeKt/PmYKckmeBiAUC
Z7UqOQIbDAUJBaOagAAKCRDPmYKckmeBiEFxAQCgziQt2l3u7jnZVij4zop+K2Lv
TVFtkbG61tf6brRzBgD/X6c6X5BRyQC51JV1I1RFRBdeMAIXzcLFg2v3WUMccQs=
=YmHI
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -0,0 +1,59 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
JriRCZy0IGFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkuc3BhY2U+iQJJBBMWCgHx
AhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAhkBNRSAAAAAABAAHHByb29m
QGFyaWFkbmUuaWRkbnM6YXJpbWVsb2R5LnNwYWNlP3R5cGU9VFhUOhSAAAAAABAA
IXByb29mQGFyaWFkbmUuaWRodHRwczovL2ZlZGkuYXJpbWVsb2R5LnNwYWNlL0Bh
cmlEFIAAAAAAEAArcHJvb2ZAYXJpYWRuZS5pZGh0dHBzOi8vZm9yZ2UuYmxpc3Mu
dG93bi9hcmkva2V5b3hpZGUtcHJvb2ZJFIAAAAAAEAAwcHJvb2ZAYXJpYWRuZS5p
ZGh0dHBzOi8vZm9yZ2UuYXJpbWVsb2R5LnNwYWNlL2FyaS9rZXlveGlkZS1wcm9v
ZkYUgAAAAAAQAC1wcm9vZkBhcmlhZG5lLmlkaHR0cHM6Ly9jb2RlYmVyZy5vcmcv
YXJpbWVsb2R5L2tleW94aWRlLXByb29mZRSAAAAAABAATHByb29mQGFyaWFkbmUu
aWRodHRwczovL2Jza3kuYXBwL3Byb2ZpbGUvZGlkOnBsYzp5Y3Q2Y3ZnZmlwbmdp
enJ5NXVtemt4cjMvcG9zdC8zbGlpbnFvdHF0YzIyFiEE7o3rjWKHLnoJHirfz5mC
nJJngYgFAmivQJsFCQ0/jT4ACgkQz5mCnJJngYgdtQD+K8AMkLvR1ZKxl0tw8/FO
vwS9HknEW13GajSAY/W1/NoA/17mnVnlTFhepKo1ETnxe2BpdOaKR85K0n2qffzC
8SAAtB1hcmkgbWVsb2R5IDxhcmlAYXJpbWVsb2R5Lm1lPoiTBBMWCgA7FiEE7o3r
jWKHLnoJHirfz5mCnJJngYgFAmTVtN0CGwMFCwkIBwICIgIGFQoJCAsCBBYCAwEC
HgcCF4AACgkQz5mCnJJngYhgmwD+ME3CwlOWZX0kyaxbRoUClgOg8bVDuwnoJFbv
EoJGNyoBANf2Ko2db0mqjwzeNd+75oZIX3bqGDozdIqbTY9/btsBiQIKBBMWCgGy
AhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheABQkIgw/zFiEE7o3rjWKHLnoJ
Hirfz5mCnJJngYgFAme1wUA2FIAAAAAAEAAdcHJvb2ZAYXJpYWRuZS5pZGh0dHBz
Oi8vaWNlLmFyaW1lbG9keS5tZS9AYXJpWxSAAAAAABAAQnByb29mQGFyaWFkbmUu
aWRodHRwczovL2dpc3QuZ2l0aHViLmNvbS9hcmltZWxvZHkvMzY2ZGMyYjZhYWVk
ZWMxOWU2MTRiN2NlY2U5Yzg2OWQyFIAAAAAAEAAZcHJvb2ZAYXJpYWRuZS5pZGRu
czphcmltZWxvZHkubWU/dHlwZT1UWFRlFIAAAAAAEABMcHJvb2ZAYXJpYWRuZS5p
ZGh0dHBzOi8vYnNreS5hcHAvcHJvZmlsZS9kaWQ6cGxjOnljdDZjdmdmaXBuZ2l6
cnk1dW16a3hyMy9wb3N0LzNsaWlucW90cXRjMjJEFIAAAAAAEAArcHJvb2ZAYXJp
YWRuZS5pZGh0dHBzOi8vZ2l0LmFyaW1lbG9keS5tZS9hcmkva2V5b3hpZGVfcHJv
b2YACgkQz5mCnJJngYh3+QD+Pbo3bM4oWtUicGUGEp4jiFoBqSNlyl9rFPY0ODDS
DxEBANaXz/No/Hn3mEwNdrFigj/YPm7TH/4UBbHAxN6hDggPiQJGBBMWCgHuAhsD
BQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheASRSAAAAAABAAMHByb29mQGFyaWFk
bmUuaWRodHRwczovL2ZvcmdlLmFyaW1lbG9keS5zcGFjZS9hcmkva2V5b3hpZGUt
cHJvb2ZGFIAAAAAAEAAtcHJvb2ZAYXJpYWRuZS5pZGh0dHBzOi8vY29kZWJlcmcu
b3JnL2FyaW1lbG9keS9rZXlveGlkZS1wcm9vZjoUgAAAAAAQACFwcm9vZkBhcmlh
ZG5lLmlkaHR0cHM6Ly9mZWRpLmFyaW1lbG9keS5zcGFjZS9AYXJpZRSAAAAAABAA
THByb29mQGFyaWFkbmUuaWRodHRwczovL2Jza3kuYXBwL3Byb2ZpbGUvZGlkOnBs
Yzp5Y3Q2Y3ZnZmlwbmdpenJ5NXVtemt4cjMvcG9zdC8zbGlpbnFvdHF0YzIyNRSA
AAAAABAAHHByb29mQGFyaWFkbmUuaWRkbnM6YXJpbWVsb2R5LnNwYWNlP3R5cGU9
VFhURBSAAAAAABAAK3Byb29mQGFyaWFkbmUuaWRodHRwczovL2ZvcmdlLmJsaXNz
LnRvd24vYXJpL2tleW94aWRlLXByb29mFiEE7o3rjWKHLnoJHirfz5mCnJJngYgF
AmivQJsFCQ0/jT4ACgkQz5mCnJJngYhk/wEAuQMYpUgyLqcYvOh1A+7f/t+DUXjz
YjQtLYw37oAESREA/074iJNi9GHGIjxYfp5lBkZxqGew1GAFIKx7Yzp64WQFuDME
Z7UmihYJKwYBBAHaRw8BAQdAlt+HmIscSlmd0yB6SpjOpOtSgAnxkgt3hYfR1zD2
05qI9QQYFgoAJgIbAhYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJor0AjBQkKYBsZ
AIF2IAQZFgoAHRYhBCW7l1aYFH8/dnGeDGC18DhuPdt+BQJntSaKAAoJEGC18Dhu
Pdt+7H4A/jLnJ2uOcYExfsa4HaeHnlJF2xxKJexnrqe2eNfJBxtaAQCkO67sWpfN
dyeW65nE0UNPvjRGfOzrS1N6mUOoYZZwBAkQz5mCnJJngYgc2gD/cFhjrwPdex9g
ZYk7jH29wQ9RpR9dEhf0C20nFZJLawgBAOBbzw4/O7OslSoIjhGs4pw9hJIBK7ds
PI6g3CeX0DUFuDMEZ7UmmRYJKwYBBAHaRw8BAQdAadTiVcUtyGEpiQI+yE/6O+5G
w2h51oM4ranh2RALwm6IfgQYFgoAJgIbIBYhBO6N641ihy56CR4q38+ZgpySZ4GI
BQJor0AjBQkKYBsKAAoJEM+ZgpySZ4GIoMAA/jB/exnjGvsMKuNW09bI29bsKHNW
SQLjEnuuByN6Spq6AP9yPUumsSEHr0W71iefMuNFJZnF+8qSk+uywQ/5ET+PBbg4
BGe1KjkSCisGAQQBl1UBBQEBB0D7+TnzpbU4fGd3MMk2lt37CqPKOvQPkhfF8OzT
Rp28HQMBCAeIfgQYFgoAJgIbDBYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJor0Ak
BQkKYBdqAAoJEM+ZgpySZ4GIlIoA/0fv2UQyhixu7Vkq7IeQ+NxUuEVCIGmrAu6k
ScT13ikjAQCPpIubU848yXcDUvxgcGAS7yNADU1dAWAZOi34WxajAQ==
=caf3
-----END PGP PUBLIC KEY BLOCK-----

View file

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

View file

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

View file

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

View file

@ -45,6 +45,23 @@ function fill_list(list) {
});
}
export function hijackClickEvent(container, link) {
container.addEventListener('click', event => {
if (event.target.tagName.toLowerCase() === 'a') return;
event.preventDefault();
link.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
button: event.button,
}));
});
}
document.addEventListener("DOMContentLoaded", () => {
[...document.querySelectorAll(".typeout")]
.filter((e) => e.innerText != "")

View file

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

View file

@ -8,11 +8,11 @@
// please use this flag to link to an LGBTQI+-supporting page
// of your choosing!
//
// web: https://arimelody.me
// source: https://git.arimelody.me/ari/prideflag
// web: https://arimelody.space
// source: https://forge.arimelody.space/ari/prideflag
//
const pride_url = "https://git.arimelody.me/ari/prideflag";
const pride_url = "https://forge.arimelody.space/ari/prideflag";
const pride_flag_svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,9 @@ CREATE TABLE arimelody.account (
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
fail_attempts INT NOT NULL DEFAULT 0,
locked BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);

View file

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

View file

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

45
view/index.go Normal file
View file

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

View file

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

31
view/static.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -6,22 +6,22 @@
<meta name="author" content="{{.PrintArtists true true}}">
<meta name="keywords" content="{{.PrintArtists true false}}, music, {{.Title}}, {{.ID}}, {{.ReleaseDate.Year}}">
<meta property="og:url" content="https://arimelody.me/music/{{.ID}}">
<meta property="og:url" content="https://arimelody.space/music/{{.ID}}">
<meta property="og:type" content="website">
<meta property="og:locale" content="en_IE">
<meta property="og:site_name" content="ari melody music">
<meta property="og.Title" content="{{.Title}} - {{.PrintArtists true true}}">
<meta property="og:site_name" content="ari melody 💫">
<meta property="og:title" content="{{.PrintArtists true true}} - {{.Title}}">
<meta property="og:description" content="Stream &quot;{{.Title}}&quot; by {{.PrintArtists true true}} on all platforms!">
<meta property="og:image" content="https://arimelody.me{{.GetArtwork}}">
<meta property="og:image" content="https://arimelody.space{{.GetArtwork}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@funniduck">
<meta name="twitter:creator" content="@funniduck">
<meta property="twitter:domain" content="arimelody.me">
<meta property="twitter:url" content="https://arimelody.me/music/{{.ID}}">
<meta name="twitter.Title" content="{{.PrintArtists true true}} - {{.Title}}">
<meta property="twitter:domain" content="arimelody.space">
<meta property="twitter:url" content="https://arimelody.space/music/{{.ID}}">
<meta name="twitter:title" content="{{.PrintArtists true true}} - {{.Title}}">
<meta name="twitter:description" content="Stream &quot;{{.Title}}&quot; by {{.PrintArtists true true}} on all platforms!">
<meta name="twitter:image" content="https://arimelody.me{{.GetArtwork}}">
<meta name="twitter:image" content="https://arimelody.space{{.GetArtwork}}">
<meta name="twitter:image:alt" content="Cover art for &quot;{{.Title}}&quot;">
<link rel="stylesheet" href="/style/main.css">
@ -34,7 +34,7 @@
<div id="background" style="background-image: url({{.GetArtwork}})"></div>
<a href="/music" id="go-back" title="back to arimelody.me">&lt;</a>
<a href="/music" id="go-back" title="back to arimelody.space">&lt;</a>
<br><br>
<div id="music-container">

View file

@ -6,9 +6,9 @@
<meta property="og:title" content="ari melody music">
<meta property="og:type" content="website">
<meta property="og:url" content="www.arimelody.me/music">
<meta property="og:image" content="https://www.arimelody.me/img/favicon.png">
<meta property="og:site_name" content="ari melody">
<meta property="og:url" content="www.arimelody.space/music">
<meta property="og:image" content="https://www.arimelody.space/img/favicon.png">
<meta property="og:site_name" content="ari melody 💫">
<meta property="og:description" content="music from your local SPACEGIRL 💫">
<link rel="stylesheet" href="/style/main.css">
@ -72,7 +72,7 @@
</p>
<blockquote>
music used: ari melody - free2play<br>
<a href="/music/free2play">https://arimelody.me/music/free2play</a><br>
<a href="/music/free2play">https://arimelody.space/music/free2play</a><br>
licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">CC BY-SA 4.0</a>.
</blockquote>
<p>
@ -84,7 +84,7 @@
if you do happen to use my work in something you're particularly proud of, feel free to send it my way!
</p>
<p>
&gt; <a href="mailto:ari@arimelody.me">ari@arimelody.me</a>
&gt; <a href="mailto:ari@arimelody.space">ari@arimelody.space</a>
</p>
</div>
</main>