Compare commits

..

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

137 changed files with 1768 additions and 3752 deletions

View file

@ -7,14 +7,14 @@ tmp_dir = "tmp"
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["uploads", "test", "db", "res"]
exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"

View file

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

View file

@ -1,50 +0,0 @@
on:
push:
branches:
- main
env:
EXEC: arimelody-web
REMOTE: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
PORT: ${{ secrets.SSH_PORT }}
jobs:
deploy:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '^1.25.1'
- name: Run tests
run: go test -v ./model
- name: Build binary
run: make build
- name: Bundle tarball
run: make bundle
- name: Set up SSH keys
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Copy to production server
run: |
ssh-keyscan -p $PORT ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
scp -P $PORT ./$EXEC.tar.gz $REMOTE:~/
- name: Restart production
run: |
ssh -o StrictHostKeyChecking=no $REMOTE -p $PORT << EOT
cd ${{ secrets.DEPLOY_DIR }}
tar xzf ~/$EXEC.tar.gz
/bin/bash ~/restart.sh
rm ~/$EXEC.tar.gz
EOT

View file

@ -2,11 +2,11 @@ EXEC = arimelody-web
.PHONY: $(EXEC)
build:
$(EXEC):
GOOS=linux GOARCH=amd64 go build -o $(EXEC)
bundle: build
tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/
bundle: $(EXEC)
tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/
clean:
rm $(EXEC) $(EXEC).tar.gz

View file

@ -1,12 +1,17 @@
# ari melody website
# arimelody.me
home to your local SPACEGIRL! 💫
---
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!
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!
## build
@ -42,6 +47,3 @@ 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

@ -7,7 +7,6 @@ import (
"net/url"
"os"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
@ -18,12 +17,12 @@ import (
func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/account/totp-setup", totpSetupHandler(app))
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
mux.Handle("/totp-setup", totpSetupHandler(app))
mux.Handle("/totp-confirm", totpConfirmHandler(app))
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
mux.Handle("/account/password", changePasswordHandler(app))
mux.Handle("/account/delete", deleteAccountHandler(app))
mux.Handle("/password", changePasswordHandler(app))
mux.Handle("/delete", deleteAccountHandler(app))
return mux
}
@ -45,7 +44,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
}
accountResponse struct {
adminPageData
Session *model.Session
TOTPs []TOTP
}
)
@ -65,8 +64,8 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Message = sessionMessage
session.Error = sessionError
err = templates.AccountTemplate.Execute(w, accountResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = accountTemplate.Execute(w, accountResponse{
Session: session,
TOTPs: totps,
})
if err != nil {
@ -170,7 +169,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
}
type totpConfirmData struct {
adminPageData
Session *model.Session
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
@ -179,9 +178,13 @@ type totpConfirmData struct {
func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
type totpSetupData struct {
Session *model.Session
}
session := r.Context().Value("session").(*model.Session)
err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -218,9 +221,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
})
err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -234,8 +235,8 @@ func totpSetupHandler(app *model.AppState) http.Handler {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
@ -266,6 +267,11 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
code := r.FormValue("totp")
if len(code) != controller.TOTP_CODE_LENGTH {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
@ -285,22 +291,23 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
code := r.FormValue("totp")
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
if code != confirmCode {
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if code != confirmCodeOffset {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
return
}
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
@ -321,23 +328,18 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
if len(r.URL.Path) < 2 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.URL.Path[1:]
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
session := r.Context().Value("session").(*model.Session)
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {

View file

@ -5,82 +5,49 @@ import (
"net/http"
"strings"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model"
"arimelody-web/controller"
)
func serveArtists(app *model.AppState) http.Handler {
func serveArtist(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/")
artistID := slices[0]
if len(artistID) > 0 {
serveArtist(app, artistID).ServeHTTP(w, r)
return
}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistsResponse struct {
adminPageData
Artists []*model.Artist
}
err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Artists: artists,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveArtist(app *model.AppState, artistID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
artist, err := controller.GetArtist(app.DB, artistID)
slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0]
artist, err := controller.GetArtist(app.DB, id)
if err != nil {
if artist == nil {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil {
fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistResponse struct {
adminPageData
Session *model.Session
Artist *model.Artist
Credits []*model.Credit
}
err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
session := r.Context().Value("session").(*model.Session)
err = artistTemplate.Execute(w, ArtistResponse{
Session: session,
Artist: artist,
Credits: credits,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}

View file

@ -7,7 +7,7 @@
{{range $Artist := .Artists}}
<li class="new-artist"
data-id="{{$Artist.ID}}"
hx-get="/admin/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-target="#editcredits ul"
hx-swap="beforeend"
>

View file

@ -3,8 +3,8 @@
<h2>Editing: Credits</h2>
<a id="add-credit"
class="button new"
href="/admin/releases/{{.ID}}/addcredit"
hx-get="/admin/releases/{{.ID}}/addcredit"
href="/admin/release/{{.ID}}/addcredit"
hx-get="/admin/release/{{.ID}}/addcredit"
hx-target="body"
hx-swap="beforeend"
>Add</a>

View file

@ -5,17 +5,17 @@
</div>
<div class="release-info">
<h3 class="release-title">
<a href="/admin/releases/{{.ID}}">{{.Title}}</a>
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
<small>
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
<span title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
{{if not .Visible}}(hidden){{end}}
</small>
</h3>
<p class="release-artists">{{.PrintArtists true true}}</p>
<p class="release-type-single">{{.ReleaseType}}
(<a href="/admin/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
(<a href="/admin/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
<div class="release-actions">
<a href="/admin/releases/{{.ID}}">Edit</a>
<a href="/admin/release/{{.ID}}">Edit</a>
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
</div>
</div>

View file

@ -8,7 +8,7 @@
</li>
<li class="new-track"
data-id="{{$Track.ID}}"
hx-get="/admin/releases/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
hx-target="#edittracks ul"
hx-swap="beforeend"
>

View file

@ -3,8 +3,8 @@
<h2>Editing: Tracks</h2>
<a id="add-track"
class="button new"
href="/admin/releases/{{.Release.ID}}/addtrack"
hx-get="/admin/releases/{{.Release.ID}}/addtrack"
href="/admin/release/{{.Release.ID}}/addtrack"
hx-get="/admin/release/{{.Release.ID}}/addtrack"
hx-target="body"
hx-swap="beforeend"
>Add</a>

View file

@ -6,23 +6,17 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/view"
"golang.org/x/crypto/bcrypt"
)
type adminPageData struct {
Path string
Session *model.Session
}
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
@ -44,30 +38,15 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(accountHandler(app)))
mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app))))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/releases", requireAccount(serveReleases(app)))
mux.Handle("/releases/", requireAccount(serveReleases(app)))
mux.Handle("/artists", requireAccount(serveArtists(app)))
mux.Handle("/artists/", requireAccount(serveArtists(app)))
mux.Handle("/tracks", requireAccount(serveTracks(app)))
mux.Handle("/tracks/", requireAccount(serveTracks(app)))
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/static/admin.css" {
http.ServeFile(w, r, "./admin/static/admin.css")
return
}
if r.URL.Path == "/static/admin.js" {
http.ServeFile(w, r, "./admin/static/admin.js")
return
}
requireAccount(
http.StripPrefix("/static",
view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
}))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
@ -84,18 +63,12 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(app.DB, false, 3, true)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releaseCount, err := controller.GetReleaseCount(app.DB, false)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
@ -103,12 +76,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artistCount, err := controller.GetArtistCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
@ -116,31 +83,19 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
trackCount, err := controller.GetTrackCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
adminPageData
Releases []*model.Release
ReleaseCount int
Artists []*model.Artist
ArtistCount int
Tracks []*model.Track
TrackCount int
Session *model.Session
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
err = templates.IndexTemplate.Execute(w, IndexData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = indexTemplate.Execute(w, IndexData{
Session: session,
Releases: releases,
ReleaseCount: releaseCount,
Artists: artists,
ArtistCount: artistCount,
Tracks: tracks,
TrackCount: trackCount,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
@ -160,8 +115,12 @@ func registerAccountHandler(app *model.AppState) http.Handler {
return
}
type registerData struct {
Session *model.Session
}
render := func() {
err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
err := registerTemplate.Execute(w, registerData{ Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -266,8 +225,12 @@ func loginHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
type loginData struct {
Session *model.Session
}
render := func() {
err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
err := loginTemplate.Execute(w, loginData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -311,20 +274,11 @@ 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))
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.")
}
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
@ -345,8 +299,6 @@ 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
}
@ -378,8 +330,12 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
return
}
type loginTOTPData struct {
Session *model.Session
}
render := func() {
err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -421,14 +377,8 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
return
}
if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
@ -473,7 +423,7 @@ func logoutHandler(app *model.AppState) http.Handler {
Path: "/",
})
err = templates.LogoutTemplate.Execute(w, nil)
err = logoutTemplate.Execute(w, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -493,30 +443,30 @@ func requireAccount(next http.Handler) http.HandlerFunc {
})
}
/*
//go:embed "static"
var staticFS embed.FS
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uri := strings.TrimPrefix(r.URL.Path, "/static")
file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri)))
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
w.WriteHeader(http.StatusOK)
w.Write(file)
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}
*/
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, r)
session, err := controller.GetSessionFromRequest(app.DB, 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)
@ -546,30 +496,3 @@ 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,7 +1,6 @@
package admin
import (
"arimelody-web/admin/templates"
"arimelody-web/log"
"arimelody-web/model"
"fmt"
@ -51,12 +50,12 @@ func logsHandler(app *model.AppState) http.Handler {
}
type LogsResponse struct {
adminPageData
Session *model.Session
Logs []*log.Log
}
err = templates.LogsTemplate.Execute(w, LogsResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = logsTemplate.Execute(w, LogsResponse{
Session: session,
Logs: logs,
})
if err != nil {

View file

@ -3,60 +3,17 @@ package admin
import (
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveReleases(app *model.AppState) http.Handler {
func serveRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0]
var action string = ""
if len(slices) > 1 {
action = slices[1]
}
if len(releaseID) > 0 {
serveRelease(app, releaseID, action).ServeHTTP(w, r)
return
}
type ReleasesData struct {
adminPageData
Releases []*model.Release
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
adminPageData: adminPageData{
Path: r.URL.Path,
Session: session,
},
Releases: releases,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true)
@ -65,13 +22,13 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if len(action) > 0 {
switch action {
if len(slices) > 1 {
switch slices[1] {
case "editcredits":
serveEditCredits(release).ServeHTTP(w, r)
return
@ -99,20 +56,16 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
}
type ReleaseResponse struct {
adminPageData
Session *model.Session
Release *model.Release
}
for i, track := range release.Tracks {
track.Number = i + 1
}
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = releaseTemplate.Execute(w, ReleaseResponse{
Session: session,
Release: release,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -121,9 +74,9 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release)
err := editCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -133,7 +86,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -144,12 +97,12 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.AddCreditTemplate.Execute(w, response{
err = addCreditTemplate.Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
if err != nil {
fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -160,7 +113,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -170,9 +123,9 @@ func serveNewCredit(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist)
err = newCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -181,9 +134,9 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditLinksTemplate.Execute(w, release)
err := editLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -193,11 +146,17 @@ func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
type editTracksData struct { Release *model.Release }
type editTracksData struct {
Release *model.Release
Add func(a int, b int) int
}
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
err := editTracksTemplate.Execute(w, editTracksData{
Release: release,
Add: func(a, b int) int { return a + b },
})
if err != nil {
fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -207,7 +166,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -218,14 +177,15 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.AddTrackTemplate.Execute(w, response{
err = addTrackTemplate.Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
if err != nil {
fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
})
}
@ -234,7 +194,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -244,10 +204,11 @@ func serveNewTrack(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track)
err = newTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
})
}

View file

@ -1,266 +1,88 @@
@import url("/style/prideflag.css");
@import url("/font/inter/inter.css");
:root {
--bg-0: #101010;
--bg-1: #181818;
--bg-2: #282828;
--bg-3: #404040;
--fg-0: #b0b0b0;
--fg-1: #c0c0c0;
--fg-2: #d0d0d0;
--fg-3: #e0e0e0;
--col-shadow-0: #0002;
--col-shadow-1: #0004;
--col-shadow-2: #0006;
--col-highlight-0: #ffffff08;
--col-highlight-1: #fff1;
--col-highlight-2: #fff2;
--col-new: #b3ee5b;
--col-on-new: #1b2013;
--col-save: #6fd7ff;
--col-on-save: #283f48;
--col-delete: #ff7171;
--col-on-delete: #371919;
--col-warn: #ffe86a;
--col-on-warn: var(--bg-0);
--col-warn-hover: #ffec81;
--shadow-sm:
0 1px 2px var(--col-shadow-2),
inset 0 1px 1px var(--col-highlight-2);
--shadow-md:
0 2px 4px var(--col-shadow-1),
inset 0 2px 2px var(--col-highlight-1);
--shadow-lg:
0 4px 8px var(--col-shadow-0),
inset 0 4px 4px var(--col-highlight-0);
}
@media (prefers-color-scheme: light) {
:root {
--bg-0: #e8e8e8;
--bg-1: #f0f0f0;
--bg-2: #f8f8f8;
--bg-3: #ffffff;
--fg-0: #606060;
--fg-1: #404040;
--fg-2: #303030;
--fg-3: #202020;
--col-shadow-0: #0002;
--col-shadow-1: #0004;
--col-shadow-2: #0008;
--col-highlight-0: #fff2;
--col-highlight-1: #fff4;
--col-highlight-2: #fff8;
--col-warn: #ffe86a;
--col-on-warn: var(--fg-3);
--col-warn-hover: #ffec81;
}
}
@media (prefers-color-scheme: green) {
:root {
--bg-0: #d0d9c7;
--bg-1: #e2e5de;
--bg-2: #f1f1f1;
--bg-3: #ffffff;
--fg-0: #626f54;
--fg-1: #505c43;
--fg-2: #49523e;
--fg-3: #2c3522;
}
}
@media (prefers-color-scheme: purple) {
:root {
--bg-0: #15121c;
--bg-1: #1e1a27;
--bg-2: #302a3d;
--bg-3: #4a4358;
--fg-0: #9e8fbf;
--fg-1: #a29bb3;
--fg-2: #b9b0cd;
--fg-3: #dcd5ec;
}
}
@media (prefers-color-scheme: dark) {
img.icon {
-webkit-filter: invert(.9);
filter: invert(.9);
}
}
body {
width: calc(100% - 180px);
width: 100%;
height: calc(100vh - 1em);
margin: 0 0 0 180px;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
font-family: "Inter", sans-serif;
font-size: 16px;
color: var(--fg-0);
background: var(--bg-0);
transition: background .1s ease-out, color .1s ease-out;
color: #303030;
background: #f0f0f0;
}
h1, h2, h3, h4, h5, h6 {
color: var(--fg-3);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
nav {
position: fixed;
top: 0;
left: 0;
width: 180px;
height: calc(100vh - 2em);
margin: 0;
padding: 1em 0;
width: min(720px, calc(100% - 2em));
height: 2em;
margin: 1em auto;
display: flex;
flex-direction: column;
flex-direction: row;
justify-content: left;
background-color: var(--bg-1);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
user-select: none;
background: #f8f8f8;
border-radius: 4px;
border: 1px solid #808080;
}
nav .icon {
width: fit-content;
height: fit-content;
padding: 0;
margin: 0 auto 1em auto;
height: 100%;
}
nav .title {
width: auto;
height: 100%;
margin: 0 1em 0 0;
display: flex;
border-radius: 100%;
box-shadow: var(--shadow-sm);
overflow: clip;
}
nav .icon img {
width: 3em;
height: 3em;
line-height: 2em;
text-decoration: none;
color: inherit;
}
.nav-item {
width: auto;
height: 100%;
margin: 0px;
padding: 0 1em;
display: flex;
color: var(--fg-2);
line-height: 2em;
font-weight: 500;
transition: color .1s ease-out, background-color .1s ease-out;
}
.nav-item:hover {
color: var(--bg-2);
background-color: var(--fg-2);
background: #00000010;
text-decoration: none;
}
.nav-item.active {
border-left: 4px solid var(--fg-2);
}
.nav-item.active a {
padding-left: calc(1em - 3.5px);
}
nav a {
padding: .2em 1em;
text-decoration: none;
color: inherit;
width: 100%;
}
nav a.active {
border-left: 5px solid var(--fg-0);
padding-left: calc(1em - 5px);
}
nav hr {
width: calc(100% - 2em);
margin: .5em auto;
border: none;
border-bottom: 1px solid var(--fg-0);
}
nav .section-label {
margin: .6em 0 .1em 1.6em;
font-size: .6em;
text-transform: uppercase;
font-weight: 600;
}
#toggle-nav {
position: fixed;
top: 16px;
left: 16px;
padding: 8px;
width: 48px;
height: 48px;
display: none;
justify-content: center;
align-items: center;
z-index: 1;
}
#toggle-nav img {
width: 100%;
height: 100%;
object-fit: cover;
transform: translate(1px, 1px);
}
#toggle-nav img:hover {
-webkit-filter: invert(.9);
filter: invert(.9);
}
@media (prefers-color-scheme: dark) {
#toggle-nav img {
-webkit-filter: invert(.9);
filter: invert(.9);
}
#toggle-nav img:hover {
-webkit-filter: none;
filter: none;
}
nav #logout {
/* margin-left: auto; */
}
main {
width: 720px;
max-width: calc(100% - 2em);
height: fit-content;
min-height: calc(100vh - 2em);
width: min(720px, calc(100% - 2em));
margin: 0 auto;
padding: 1em;
}
main.dashboard {
width: 100%;
}
a {
color: inherit;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color .1s ease-out, background-color .1s ease-out;
}
/*
a:hover {
text-decoration: underline;
}
*/
img.icon {
a img.icon {
height: .8em;
transition: filter .1s ease-out;
}
code {
@ -272,24 +94,7 @@ code {
.cards {
width: 100%;
height: fit-content;
display: flex;
gap: 2em;
flex-wrap: wrap;
}
.card {
flex-basis: 40em;
padding: 1em;
background: var(--bg-1);
border-radius: 16px;
box-shadow: var(--shadow-lg);
transition: background .1s ease-out, color .1s ease-out;
}
main:not(.dashboard) .card {
margin-bottom: 1em;
}
@ -297,7 +102,7 @@ main:not(.dashboard) .card {
margin: 0 0 .5em 0;
}
.card-header {
.card-title {
margin-bottom: 1em;
display: flex;
gap: 1em;
@ -305,31 +110,21 @@ main:not(.dashboard) .card {
align-items: center;
justify-content: space-between;
}
.card-header h1,
.card-header h2,
.card-header h3 {
margin: 0;
}
.card-header a:hover {
text-decoration: underline;
}
header :is(h1, h2, h3) small,
.card-header :is(h1, h2, h3) small {
display: inline-block;
font-size: .6em;
transform: translateY(-0.1em);
color: var(--fg-0);
.card-title h1,
.card-title h2,
.card-title h3 {
margin: 0;
}
.flex-fill {
flex-grow: 1;
}
.artists-group {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1em;
@media screen and (max-width: 520px) {
body {
font-size: 12px;
}
}
@ -338,15 +133,17 @@ header :is(h1, h2, h3) small,
#error {
margin: 0 0 1em 0;
padding: 1em;
border-radius: 8px;
color: #101010;
border-radius: 4px;
background: #ffffff;
border: 1px solid #888;
}
#message {
background: #a9dfff;
border-color: #599fdc;
}
#error {
background: #ffa9b8;
border-color: #dc5959;
}
@ -355,54 +152,52 @@ a.delete:not(.button) {
color: #d22828;
}
.button, button {
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: 4px;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
background: var(--bg-2);
border: none;
border-radius: 10em;
box-shadow: var(--shadow-sm);
font-weight: 500;
transition: background .1s ease-out, color .1s ease-out;
cursor: pointer;
user-select: none;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
.button, button {
color: inherit;
}
.button.new, button.new {
color: var(--col-on-new);
background: var(--col-new);
background: #c4ff6a;
border-color: #84b141;
}
.button.save, button.save {
color: var(--col-on-save);
background: var(--col-save);
background: #6fd7ff;
border-color: #6f9eb0;
}
.button.delete, button.delete {
color: var(--col-on-delete);
background: var(--col-delete);
background: #ff7171;
border-color: #7d3535;
}
.button:hover, button:hover {
color: var(--bg-3);
background: var(--fg-3);
background: #fff;
border-color: #d0d0d0;
}
.button:active, button:active {
color: var(--bg-2);
background: var(--fg-0);
background: #d0d0d0;
border-color: #808080;
}
.button[disabled], button[disabled] {
color: var(--fg-0) !important;
background: var(--bg-3) !important;
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: default !important;
cursor: not-allowed !important;
}
@ -410,79 +205,24 @@ button:active, .button:active {
form {
width: 100%;
display: block;
color: var(--fg-0);
}
form label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
form input[type="text"],
form input[type="password"] {
width: 16em;
max-width: 100%;
margin: .5em 0;
padding: .3em .5em;
form input {
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
background-color: var(--bg-0);
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
@media screen and (max-width: 720px) {
main {
padding-top: 0;
}
body {
width: 100%;
margin: 0;
font-size: 16px;
}
nav {
width: 100%;
left: -100%;
font-size: 24px;
z-index: 1;
transition: transform .2s cubic-bezier(.2,0,.5,1);
}
nav.open {
transform: translateX(100%);
}
#toggle-nav {
display: flex;
}
main > h1:first-of-type {
margin-left: 68px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
main > header {
margin-left: 68px;
}
main > header h1 {
display: flex;
flex-direction: column;
font-size: 1.5em;
}
.card {
flex-basis: 100%;
max-width: calc(100vw - 4em);
}
.artists-group {
grid-template-columns: repeat(3, 1fr);
}
}

View file

@ -69,28 +69,3 @@ export function makeMagicList(container, itemSelector, callback) {
if (callback) callback();
});
}
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("readystatechange", () => {
const navbar = document.getElementById("navbar");
document.getElementById("toggle-nav").addEventListener("click", event => {
event.preventDefault();
navbar.classList.toggle("open");
})
});

View file

@ -1,27 +0,0 @@
.artist {
padding: .5em;
color: var(--fg-3);
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: background .1s ease-out, color .1s ease-out;
}
.artist:hover {
background: var(--bg-1);
text-decoration: hover;
}
.artist .artist-avatar {
width: 100%;
object-fit: cover;
border-radius: 8px;
}
.artist .artist-name {
display: block;
}

View file

@ -1,7 +0,0 @@
import { hijackClickEvent } from "./admin.js";
document.addEventListener("readystatechange", () => {
document.querySelectorAll(".artists-group .artist").forEach(el => {
hijackClickEvent(el, el.querySelector("a.artist-name"))
});
});

View file

@ -11,11 +11,8 @@ label {
align-items: center;
color: inherit;
}
form#change-password input,
form#delete-account input {
width: 20em;
min-width: auto;
max-width: calc(100% - 1em - 2px);
input {
width: min(20rem, calc(100% - 1rem));
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
@ -28,14 +25,12 @@ form#delete-account input {
.mfa-device {
padding: .75em;
background: #f8f8f8f8;
border: 1px solid #808080;
border-radius: 8px;
margin-bottom: .5em;
display: flex;
justify-content: space-between;
color: var(--fg-3);
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
}
.mfa-device div {
@ -51,14 +46,3 @@ form#delete-account input {
.mfa-device .mfa-device-name {
font-weight: bold;
}
.mfa-device form input {
display: none !important;
}
.mfa-actions {
display: flex;
flex-direction: row;
gap: .5em;
flex-wrap: wrap;
}

View file

@ -1,3 +1,7 @@
h1 {
margin: 0 0 1em 0;
}
#artist {
margin-bottom: 1em;
padding: 1.5em;
@ -5,9 +9,9 @@
flex-direction: row;
gap: 1.2em;
border-radius: 16px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist-avatar {
@ -23,8 +27,7 @@
cursor: pointer;
}
.artist-avatar #remove-avatar {
margin-top: .5em;
padding: .3em .6em;
padding: .3em .4em;
}
.artist-info {
@ -50,8 +53,8 @@ input[type="text"] {
font-family: inherit;
font-weight: inherit;
color: inherit;
background: var(--bg-0);
border: none;
background: #ffffff;
border: 1px solid transparent;
border-radius: 4px;
outline: none;
}
@ -71,7 +74,7 @@ input[type="text"]:focus {
justify-content: right;
}
.card-header a.button {
.card-title a.button {
text-decoration: none;
}
@ -82,17 +85,9 @@ input[type="text"]:focus {
flex-direction: row;
gap: 1em;
align-items: center;
border-radius: 16px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
cursor: pointer;
transition: background .1s;
}
.credit:hover {
background: var(--bg-1);
background: #f8f8f8;
border-radius: 8px;
border: 1px solid #808080;
}
.release-artwork {
@ -101,14 +96,8 @@ input[type="text"]:focus {
border-radius: 4px;
}
.credit-info {
overflow: hidden;
}
.credit-info h3,
.credit-info p {
margin: 0;
font-size: .9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -1,5 +1,3 @@
import { hijackClickEvent } from "./admin.js";
const artistID = document.getElementById("artist").dataset.id;
const nameInput = document.getElementById("name");
const avatarImg = document.getElementById("avatar");
@ -79,9 +77,3 @@ removeAvatarBtn.addEventListener("click", () => {
avatarImg.src = "/img/default-avatar.png"
saveBtn.disabled = false;
});
document.addEventListener('readystatechange', () => {
document.querySelectorAll('#releases .credit').forEach(el => {
hijackClickEvent(el, el.querySelector('.credit-name a'));
});
});

View file

@ -12,10 +12,8 @@ input[type="text"] {
gap: 1.2em;
border-radius: 8px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.release-artwork {
@ -31,9 +29,7 @@ input[type="text"] {
cursor: pointer;
}
.release-artwork #remove-artwork {
margin-top: .5em;
padding: .3em .6em;
background: var(--bg-3);
padding: .3em .4em;
}
.release-info {
@ -58,17 +54,17 @@ input[type="text"] {
background: transparent;
outline: none;
cursor: pointer;
transition: background .1s ease-out, border-color .1s ease-out;
}
#title:hover {
background: var(--bg-3);
border-color: var(--fg-0);
background: #ffffff;
border-color: #80808080;
}
#title:active,
#title:focus {
background: var(--bg-3);
background: #ffffff;
border-color: #808080;
}
.release-title small {
@ -79,21 +75,19 @@ input[type="text"] {
width: 100%;
margin: .5em 0;
border-collapse: collapse;
color: var(--fg-2);
}
.release-info table td {
padding: .2em;
border-bottom: 1px solid color-mix(in srgb, var(--fg-0) 25%, transparent);
transition: background .1s ease-out, border-color .1s ease-out;
border-bottom: 1px solid #d0d0d0;
}
.release-info table tr td:first-child {
vertical-align: top;
opacity: .5;
opacity: .66;
}
.release-info table tr td:not(:first-child) select:hover,
.release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover {
background: var(--bg-3);
background: #e8e8e8;
cursor: pointer;
}
.release-info table td select,
@ -121,25 +115,13 @@ input[type="text"] {
gap: .5em;
flex-direction: row;
justify-content: right;
color: var(--fg-3);
}
.release-actions button,
.release-actions .button {
color: var(--fg-2);
background: var(--bg-3);
}
dialog {
width: min(720px, calc(100% - 2em));
padding: 2em;
border: none;
border-radius: 16px;
color: var(--fg-0);
background-color: var(--bg-0);
box-shadow: var(--shadow-lg);
transition: color .1s ease-out, background-color .1s ease-out;
border: 1px solid #101010;
border-radius: 8px;
}
dialog header {
@ -162,7 +144,7 @@ dialog div.dialog-actions {
gap: .5em;
}
.card-header a.button {
.card-title a.button {
text-decoration: none;
}
@ -170,7 +152,7 @@ dialog div.dialog-actions {
* RELEASE CREDITS
*/
#credits .credit {
.card.credits .credit {
margin-bottom: .5em;
padding: .5em;
display: flex;
@ -178,47 +160,28 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
border-radius: 16px;
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
cursor: pointer;
transition: background .1s ease-out;
}
#credits .credit:hover {
background-color: var(--bg-1);
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
#credits .credit p {
.card.credits .credit p {
margin: 0;
}
#credits .credit .artist-avatar {
border-radius: 12px;
.card.credits .credit .artist-avatar {
border-radius: 8px;
}
#credits .credit .artist-name {
color: var(--fg-3);
.card.credits .credit .artist-name {
font-weight: bold;
}
#credits .credit .artist-role small {
.card.credits .credit .artist-role small {
font-size: inherit;
opacity: .66;
}
#credits .credit .credit-info {
overflow: hidden;
}
#credits .credit .credit-info :is(h3, p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#editcredits ul {
margin: 0;
padding: 0;
@ -234,8 +197,8 @@ dialog div.dialog-actions {
gap: 1em;
border-radius: 8px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
background: #f8f8f8f8;
border: 1px solid #808080;
}
#editcredits .credit {
@ -269,7 +232,6 @@ dialog div.dialog-actions {
margin: 0;
display: flex;
align-items: center;
color: inherit;
}
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
@ -277,17 +239,15 @@ dialog div.dialog-actions {
padding: .2em .4em;
flex-grow: 1;
font-family: inherit;
border: none;
border: 1px solid #8888;
border-radius: 4px;
color: var(--fg-2);
background: var(--bg-0);
color: inherit;
}
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
}
#editcredits .credit .artist-name {
color: var(--fg-2);
font-weight: bold;
}
@ -296,12 +256,8 @@ dialog div.dialog-actions {
opacity: .66;
}
#editcredits .credit .delete {
margin-right: .5em;
cursor: pointer;
}
#editcredits .credit .delete:hover {
text-decoration: underline;
#editcredits .credit button.delete {
margin-left: auto;
}
#addcredit ul {
@ -333,39 +289,24 @@ dialog div.dialog-actions {
* RELEASE LINKS
*/
#links ul {
padding: 0;
.card.links {
display: flex;
gap: .2em;
}
#links a img.icon {
-webkit-filter: none;
filter: none;
}
#links a.button:hover {
color: var(--bg-3) !important;
background-color: var(--fg-3) !important;
}
#links a.button[data-name="spotify"] {
color: #101010;
.card.links a.button[data-name="spotify"] {
background-color: #8cff83
}
#links a.button[data-name="apple music"] {
color: #101010;
.card.links a.button[data-name="apple music"] {
background-color: #8cd9ff
}
#links a.button[data-name="soundcloud"] {
color: #101010;
.card.links a.button[data-name="soundcloud"] {
background-color: #fdaa6d
}
#links a.button[data-name="youtube"] {
color: #101010;
.card.links a.button[data-name="youtube"] {
background-color: #ff6e6e
}
@ -448,6 +389,62 @@ dialog div.dialog-actions {
outline: 1px solid #808080;
}
/*
* RELEASE TRACKS
*/
.card.tracks .track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.card.tracks .track h3,
.card.tracks .track p {
margin: 0;
}
.card.tracks h2.track-title {
margin: 0;
display: flex;
gap: .5em;
}
.card.tracks h2.track-title .track-number {
opacity: .5;
}
.card.tracks .track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.card.tracks .track-album.empty {
color: #ff2020;
opacity: 1;
}
.card.tracks .track-description {
font-style: italic;
}
.card.tracks .track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.card.tracks .track .empty {
opacity: 0.75;
}
#edittracks ul {
padding: 0;
list-style: none;
@ -499,17 +496,15 @@ dialog div.dialog-actions {
padding: .5em;
display: flex;
gap: .5em;
background-color: var(--bg-0);
cursor: pointer;
transition: background-color .1s ease-out, color .1s ease-out;
}
#addtrack ul li.new-track:nth-child(even) {
background: color-mix(in srgb, var(--bg-0) 95%, #fff);
background: #f0f0f0;
}
#addtrack ul li.new-track:hover {
background: color-mix(in srgb, var(--bg-0) 90%, #fff);
background: #e0e0e0;
}
@media only screen and (max-width: 1105px) {

View file

@ -1,5 +1,3 @@
import { hijackClickEvent } from "./admin.js";
const releaseID = document.getElementById("release").dataset.id;
const titleInput = document.getElementById("title");
const artworkImg = document.getElementById("artwork");
@ -98,9 +96,3 @@ removeArtworkBtn.addEventListener("click", () => {
artworkData = "";
saveBtn.disabled = false;
});
document.addEventListener("readystatechange", () => {
document.querySelectorAll("#credits .credit").forEach(el => {
hijackClickEvent(el, el.querySelector(".artist-name a"));
});
});

View file

@ -1,5 +1,9 @@
@import url("/admin/static/release-list-item.css");
h1 {
margin: 0 0 .5em 0;
}
#track {
margin-bottom: 1em;
padding: .5em 1.5em 1.5em 1.5em;
@ -7,9 +11,9 @@
flex-direction: row;
gap: 1.2em;
border-radius: 16px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.track-info {
@ -45,8 +49,7 @@
font-weight: inherit;
font-family: inherit;
font-size: inherit;
background: var(--bg-0);
border: none;
border: 1px solid transparent;
border-radius: 4px;
outline: none;
color: inherit;

82
admin/static/index.css Normal file
View file

@ -0,0 +1,82 @@
@import url("/admin/static/release-list-item.css");
.artist {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist:hover {
text-decoration: hover;
}
.artist-avatar {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 100%;
}
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.track p {
margin: 0;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}

View file

@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/releases/" + id;
if (res.ok) location = "/admin/release/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => {
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artists/" + id;
location = "/admin/artist/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => {
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/tracks/" + text;
location = "/admin/track/" + text;
} else {
alert("Request failed: " + text);
console.error(text);

View file

@ -2,14 +2,8 @@ main {
width: min(1080px, calc(100% - 2em))!important
}
form#search-form {
width: calc(100% - 2em);
form {
margin: 1em 0;
padding: 1em;
border-radius: 16px;
color: var(--fg-0);
background: var(--bg-2);
box-shadow: var(--shadow-md);
}
div#search {
@ -18,25 +12,24 @@ div#search {
#search input {
margin: 0;
padding: .3em .8em;
flex-grow: 1;
border: none;
border-radius: 16px;
color: var(--fg-1);
background: var(--bg-0);
box-shadow: var(--shadow-sm);
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
#search button {
margin-left: .5em;
padding: 0 .8em;
padding: 0 .5em;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
form #filters p {
margin: .5em 0 0 0;
}
form #filters label {
color: inherit;
display: inline;
}
form #filters input {
@ -46,15 +39,6 @@ form #filters input {
#logs {
width: 100%;
overflow: scroll;
}
@media screen and (max-width: 720px) {
#logs {
font-size: 12px;
}
}
#logs table{
border-collapse: collapse;
}
@ -73,10 +57,6 @@ form #filters input {
padding: .4em .8em;
}
#logs .log {
color: var(--fg-2);
}
td, th {
width: 1%;
text-align: left;
@ -94,14 +74,13 @@ td.log-content {
white-space: collapse;
}
#logs .log:hover {
background: color-mix(in srgb, var(--fg-3) 10%, transparent);
.log:hover {
background: #fff8;
}
#logs .log.warn {
color: var(--col-on-warn);
background: var(--col-warn);
.log.warn {
background: #ffe86a;
}
#logs .log.warn:hover {
background: var(--col-warn-hover);
.log.warn:hover {
background: #ffec81;
}

View file

@ -0,0 +1,87 @@
.release {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: row;
gap: 1em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.release h3,
.release p {
margin: 0;
}
.release-artwork {
width: 96px;
display: flex;
justify-content: center;
align-items: center;
}
.release-artwork img {
width: 100%;
aspect-ratio: 1;
}
.release-title small {
opacity: .75;
}
.release-links {
margin: .5em 0;
padding: 0;
display: flex;
flex-direction: row;
list-style: none;
flex-wrap: wrap;
gap: .5em;
}
.release-links li {
flex-grow: 1;
}
.release-links a {
padding: .5em;
display: block;
border-radius: 8px;
text-decoration: none;
color: #f0f0f0;
background: #303030;
text-align: center;
transition: color .1s, background .1s;
}
.release-links a:hover {
color: #303030;
background: #f0f0f0;
}
.release-actions {
margin-top: .5em;
}
.release-actions a {
margin-right: .3em;
padding: .3em .5em;
display: inline-block;
border-radius: 4px;
background: #e0e0e0;
transition: color .1s, background .1s;
}
.release-actions a:hover {
color: #303030;
background: #f0f0f0;
text-decoration: none;
}

View file

@ -1,80 +0,0 @@
.release {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: row;
gap: 1em;
border-radius: 16px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
}
.release h3,
.release p {
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.release .release-artwork {
margin: auto 0;
width: 96px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.release .release-artwork img {
width: 100%;
aspect-ratio: 1;
}
.release .release-info {
max-width: calc(100% - 96px - 1em);
}
.release .release-title small {
opacity: .75;
}
.release .release-links {
margin: .5em 0;
padding: 0;
display: flex;
flex-direction: row;
list-style: none;
flex-wrap: wrap;
gap: .5em;
}
.release .release-actions {
margin-top: .5em;
user-select: none;
color: var(--fg-3);
}
.release .release-actions a {
margin-right: .3em;
padding: .3em .5em;
display: inline-block;
border-radius: 4px;
background: var(--bg-3);
box-shadow: var(--shadow-sm);
transition: color .1s ease-out, background .1s ease-out;
}
.release .release-actions a:hover {
background: var(--bg-0);
color: var(--fg-3);
text-decoration: none;
}

View file

@ -1,127 +0,0 @@
#tracks h2.track-title {
margin: 0;
display: flex;
gap: .5em;
}
#tracks .track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 16px;
background: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
}
#tracks .track h3,
#tracks .track p {
margin: 0;
}
#tracks h2.track-title {
margin: 0;
display: flex;
gap: .5em;
}
#tracks h2.track-title .track-number {
opacity: .5;
}
#tracks a:hover {
text-decoration: underline;
}
#tracks .track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
#tracks .track-album.empty {
color: #ff2020;
opacity: 1;
}
#tracks .track-description {
font-style: italic;
}
#tracks .track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
#tracks .track .empty {
opacity: 0.75;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
/*
justify-content: space-between;
*/
}
/*
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: color .1s ease-out, background-color .1s ease-out;
}
.track p {
margin: 0;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}
*/

125
admin/templates.go Normal file
View file

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

View file

@ -1,26 +0,0 @@
{{define "head"}}
<title>Artists - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/artists.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Artists <small>({{len .Artists}} total)</small></h2>
<a class="button new" id="create-artist">Create New</a>
</header>
{{if .Artists}}
<div class="artists-group">
{{range .Artists}}
{{block "artist" .}}{{end}}
{{end}}
</div>
{{else}}
<p>There are no artists.</p>
{{end}}
</main>
<script type="module" src="/admin/static/artists.js"></script>
{{end}}

View file

@ -1,6 +0,0 @@
{{define "artist"}}
<div class="artist">
<img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
</div>
{{end}}

View file

@ -1,24 +0,0 @@
{{define "track"}}
<div class="track" data-id="{{.ID}}">
<h2 class="track-title">
{{if .Number}}
<span class="track-number">{{.Number}}</span>
{{end}}
<a href="/admin/tracks/{{.ID}}">{{.Title}}</a>
</h2>
<h3>Description</h3>
{{if .Description}}
<p class="track-description">{{.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if .Lyrics}}
<p class="track-lyrics">{{.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}

View file

@ -1,61 +0,0 @@
{{define "head"}}
<title>Admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/releases.css">
<link rel="stylesheet" href="/admin/static/artists.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
{{end}}
{{define "content"}}
<main class="dashboard">
<h1>Dashboard</h1>
<div class="cards">
<div class="card" id="releases">
<div class="card-header">
<h2><a href="/admin/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
<a class="button new" id="create-release">Create New</a>
</div>
{{if .Artists}}
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
{{else}}
<p>There are no releases.</p>
{{end}}
</div>
<div class="card" id="artists">
<div class="card-header">
<h2><a href="/admin/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
<a class="button new" id="create-artist">Create New</a>
</div>
{{if .Artists}}
<div class="artists-group">
{{range .Artists}}
{{block "artist" .}}{{end}}
{{end}}
</div>
{{else}}
<p>There are no artists.</p>
{{end}}
</div>
<div class="card" id="tracks">
<div class="card-header">
<h2><a href="/admin/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2>
<a class="button new" id="create-track">Create New</a>
</div>
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
<br>
{{range .Tracks}}
{{block "track" .}}{{end}}
{{end}}
</div>
</div>
</main>
<script type="module" src="/admin/static/artists.js"></script>
<script type="module" src="/admin/static/index.js"></script>
{{end}}

View file

@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{block "head" .}}{{end}}
<link rel="stylesheet" href="/admin/static/admin.css">
<script type="module" src="/admin/static/admin.js"></script>
<script type="module" src="/script/vendor/htmx.min.js"></script>
</head>
<body>
<header>
<nav id="navbar">
<a href="/" class="nav icon" aria-label="ari melody" title="Return to Home">
<img src="/img/favicon.png" alt="" width="64" height="64">
</a>
<div class="nav-item{{if eq .Path "/"}} active{{end}}">
<a href="/admin">home</a>
</div>
{{if .Session.Account}}
<div class="nav-item{{if eq .Path "/logs"}} active{{end}}">
<a href="/admin/logs">logs</a>
</div>
<hr>
<p class="section-label">music</p>
<div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}">
<a href="/admin/releases/">releases</a>
</div>
<div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}">
<a href="/admin/artists/">artists</a>
</div>
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
<a href="/admin/tracks/">tracks</a>
</div>
{{end}}
<div class="flex-fill"></div>
{{if .Session.Account}}
<div class="nav-item{{if eq .Path "/account"}} active{{end}}">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>
</div>
{{else}}
<div class="nav-item{{if eq .Path "/login"}} active{{end}}">
<a href="/admin/login" id="login">log in</a>
</div>
<div class="nav-item{{if eq .Path "/register"}} active{{end}}">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav>
<button type="button" id="toggle-nav" aria-label="Navigation toggle">
<img src="/img/hamburger.svg" alt="">
</button>
</header>
{{block "content" .}}{{end}}
{{template "prideflag"}}
</body>
</html>

View file

@ -1,24 +0,0 @@
{{define "head"}}
<title>Releases - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/releases.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Releases <small>({{len .Releases}} total)</small></h1>
<a class="button new" id="create-release">Create New</a>
</header>
{{if .Releases}}
<div id="releases">
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
</div>
{{else}}
<p>There are no releases.</p>
{{end}}
</main>
{{end}}

View file

@ -1,34 +0,0 @@
{{define "head"}}
<title>Releases - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/tracks.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Tracks <small>({{len .Tracks}} total)</small></h1>
<a class="button new" id="create-track">Create New</a>
</header>
<div id="tracks">
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/tracks/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
</div>
</main>
{{end}}

View file

@ -1,191 +0,0 @@
package templates
import (
"arimelody-web/log"
_ "embed"
"fmt"
"html/template"
"strings"
"time"
)
//go:embed "html/layout.html"
var layoutHTML string
//go:embed "html/prideflag.html"
var prideflagHTML string
//go:embed "html/index.html"
var indexHTML string
//go:embed "html/register.html"
var registerHTML string
//go:embed "html/login.html"
var loginHTML string
//go:embed "html/login-totp.html"
var loginTotpHTML string
//go:embed "html/totp-confirm.html"
var totpConfirmHTML string
//go:embed "html/totp-setup.html"
var totpSetupHTML string
//go:embed "html/logout.html"
var logoutHTML string
//go:embed "html/logs.html"
var logsHTML string
//go:embed "html/edit-account.html"
var editAccountHTML string
//go:embed "html/releases.html"
var releasesHTML string
//go:embed "html/artists.html"
var artistsHTML string
//go:embed "html/tracks.html"
var tracksHTML string
//go:embed "html/edit-release.html"
var editReleaseHTML string
//go:embed "html/edit-artist.html"
var editArtistHTML string
//go:embed "html/edit-track.html"
var editTrackHTML string
//go:embed "html/components/credit/newcredit.html"
var componentNewCreditHTML string
//go:embed "html/components/credit/addcredit.html"
var componentAddCreditHTML string
//go:embed "html/components/credit/editcredits.html"
var componentEditCreditsHTML string
//go:embed "html/components/link/editlinks.html"
var componentEditLinksHTML string
//go:embed "html/components/release/release.html"
var componentReleaseHTML string
//go:embed "html/components/artist/artist.html"
var componentArtistHTML string
//go:embed "html/components/track/track.html"
var componentTrackHTML string
//go:embed "html/components/track/newtrack.html"
var componentNewTrackHTML string
//go:embed "html/components/track/addtrack.html"
var componentAddTrackHTML string
//go:embed "html/components/track/edittracks.html"
var componentEditTracksHTML string
var BaseTemplate = template.Must(
template.New("base").Funcs(
template.FuncMap{
"hasPrefix": strings.HasPrefix,
},
).Parse(strings.Join([]string{
layoutHTML,
prideflagHTML,
}, "\n")))
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
indexHTML,
componentReleaseHTML,
componentArtistHTML,
componentTrackHTML,
}, "\n"),
))
var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML))
var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML))
var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML))
var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{
"parseLevel": parseLevel,
"titleCase": titleCase,
"toLower": toLower,
"prettyTime": prettyTime,
}).Parse(logsHTML))
var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
releasesHTML,
componentReleaseHTML,
}, "\n"),
))
var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
artistsHTML,
componentArtistHTML,
}, "\n"),
))
var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
tracksHTML,
componentTrackHTML,
}, "\n"),
))
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editReleaseHTML,
componentTrackHTML,
}, "\n"),
))
var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML))
var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editTrackHTML,
componentReleaseHTML,
}, "\n"),
))
var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML))
var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML))
func parseLevel(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
}
func titleCase(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
}
func toLower(str string) string {
return strings.ToLower(str)
}
func prettyTime(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
}

View file

@ -5,53 +5,17 @@ import (
"net/http"
"strings"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model"
"arimelody-web/controller"
)
func serveTracks(app *model.AppState) http.Handler {
func serveTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/")
trackID := slices[0]
if len(trackID) > 0 {
serveTrack(app, trackID).ServeHTTP(w, r)
return
}
tracks, err := controller.GetAllTracks(app.DB)
slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0]
track, err := controller.GetTrack(app.DB, id)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TracksResponse struct {
adminPageData
Tracks []*model.Track
}
err = templates.TracksTemplate.Execute(w, TracksResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Tracks: tracks,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveTrack(app *model.AppState, trackID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -62,25 +26,28 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil {
fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err)
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TrackResponse struct {
adminPageData
Session *model.Session
Track *model.Track
Releases []*model.Release
}
err = templates.EditTrackTemplate.Execute(w, TrackResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
session := r.Context().Value("session").(*model.Session)
err = trackTemplate.Execute(w, TrackResponse{
Session: session,
Track: track,
Releases: releases,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}

View file

@ -14,10 +14,10 @@
{{end}}
<h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card">
<div class="card-header">
<h2>Change Password</h2>
</div>
<form action="/admin/account/password" method="POST" id="change-password">
<label for="current-password">Current Password</label>
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
@ -28,16 +28,14 @@
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
<br>
<button type="submit" class="save">Change Password</button>
</form>
</div>
<div class="card-title">
<h2>MFA Devices</h2>
</div>
<div class="card mfa-devices">
<div class="card-header">
<h2>MFA Devices</h2>
</div>
{{if .TOTPs}}
{{range .TOTPs}}
<div class="mfa-device">
@ -46,10 +44,7 @@
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div>
<div>
<form method="POST" action="/admin/account/totp-delete">
<input type="text" name="totp-name" value="{{.TOTP.Name}}" hidden>
<button type="submit" class="delete">Delete</button>
</form>
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
</div>
</div>
{{end}}
@ -57,30 +52,28 @@
<p>You have no MFA devices.</p>
{{end}}
<div class="mfa-actions">
<div>
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
</div>
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will need to enter your password and TOTP below.
</p>
<form action="/admin/account/delete" method="POST" id="delete-account">
<form action="/admin/account/delete" method="POST">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<br>
<button type="submit" class="delete">Delete Account</button>
</form>
</div>

View file

@ -2,7 +2,6 @@
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-artist.css">
<link rel="stylesheet" href="/admin/static/artists.css">
{{end}}
{{define "content"}}
@ -30,16 +29,16 @@
</div>
</div>
<div class="card" id="releases">
<div class="card-header">
<h2>Featured in</h2>
</div>
<div class="card-title">
<h2>Featured in</h2>
</div>
<div class="card releases">
{{if .Credits}}
{{range .Credits}}
<div class="credit">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info">
<h3 class="credit-name"><a href="/admin/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Role}}
@ -55,10 +54,10 @@
{{end}}
</div>
<div class="card" id="danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete this artist.
This action is <strong>irreversible</strong>.

View file

@ -2,13 +2,10 @@
<title>Editing {{.Release.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-release.css">
<link rel="stylesheet" href="/admin/static/releases.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
{{end}}
{{define "content"}}
<main>
<h1>Editing {{.Release.Title}}</h1>
<div id="release" data-id="{{.Release.ID}}">
<div class="release-artwork">
@ -100,21 +97,21 @@
</div>
</div>
<div class="card" id="credits">
<div class="card-header">
<h2>Credits <small>({{len .Release.Credits}} total)</small></h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/editcredits"
hx-get="/admin/releases/{{.Release.ID}}/editcredits"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card-title">
<h2>Credits ({{len .Release.Credits}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/editcredits"
hx-get="/admin/release/{{.Release.ID}}/editcredits"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card credits">
{{range .Release.Credits}}
<div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info">
<p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role">
{{.Role}}
{{if .Primary}}
@ -129,42 +126,59 @@
{{end}}
</div>
<div class="card" id="links">
<div class="card-header">
<h2>Links ({{len .Release.Links}})</h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/editlinks"
hx-get="/admin/releases/{{.Release.ID}}/editlinks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<ul>
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</ul>
<div class="card-title">
<h2>Links ({{len .Release.Links}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/editlinks"
hx-get="/admin/release/{{.Release.ID}}/editlinks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card" id="tracks">
<div class="card-header" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/edittracks"
hx-get="/admin/releases/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
{{range $i, $track := .Release.Tracks}}
{{block "track" .}}{{end}}
<div class="card links">
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</div>
<div class="card" id="danger">
<div class="card-header">
<h2>Danger Zone</h2>
<div class="card-title" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/edittracks"
hx-get="/admin/release/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card tracks">
{{range $i, $track := .Release.Tracks}}
<div class="track" data-id="{{$track.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
</h2>
<h3>Description</h3>
{{if $track.Description}}
<p class="track-description">{{$track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if $track.Lyrics}}
<p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete this release.
This action is <strong>irreversible</strong>.

View file

@ -2,8 +2,6 @@
<title>Editing Track - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-track.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
<link rel="stylesheet" href="/admin/static/releases.css">
{{end}}
{{define "content"}}
@ -41,10 +39,10 @@
</div>
</div>
<div class="card-title">
<h2>Featured in</h2>
</div>
<div class="card releases">
<div class="card-header">
<h2>Featured in</h2>
</div>
{{if .Releases}}
{{range .Releases}}
{{block "release" .}}{{end}}
@ -54,10 +52,10 @@
{{end}}
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p>
Clicking the button below will delete this track.
This action is <strong>irreversible</strong>.

72
admin/views/index.html Normal file
View file

@ -0,0 +1,72 @@
{{define "head"}}
<title>Admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/index.css">
{{end}}
{{define "content"}}
<main>
<div class="card-title">
<h1>Releases</h1>
<a class="button new" id="create-release">Create New</a>
</div>
<div class="card releases">
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
{{if not .Releases}}
<p>There are no releases.</p>
{{end}}
</div>
<div class="card-title">
<h1>Artists</h1>
<a class="button new" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
<div class="artist">
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
<div class="card-title">
<h1>Tracks</h1>
<a class="button new" id="create-track">Create New</a>
</div>
<div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
<br>
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
</main>
<script type="module" src="/admin/static/admin.js"></script>
<script type="module" src="/admin/static/index.js"></script>
{{end}}

54
admin/views/layout.html Normal file
View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{block "head" .}}{{end}}
<link rel="stylesheet" href="/admin/static/admin.css">
<script type="module" src="/script/vendor/htmx.min.js"></script>
</head>
<body>
<header>
<nav>
<img src="/img/favicon.png" alt="" class="icon">
<div class="nav-item">
<a href="/">arimelody.me</a>
</div>
<div class="nav-item">
<a href="/admin">home</a>
</div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/logs">logs</a>
</div>
{{end}}
<div class="flex-fill"></div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>
</div>
{{else}}
<div class="nav-item">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav>
</header>
{{block "content" .}}{{end}}
{{template "prideflag"}}
</body>
</html>

View file

@ -1,6 +1,7 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
form#login-totp {
width: 100%;
@ -17,15 +18,8 @@ form button {
margin-top: 1rem;
}
form input {
width: calc(100% - 1rem - 2px) !important;
}
@media screen and (max-width: 720px) {
h1 {
margin-top: 3em;
text-align: center;
}
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}

View file

@ -1,14 +1,8 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
@media screen and (max-width: 720px) {
h1 {
margin-top: 3em;
text-align: center;
}
}
form#login {
width: 100%;
display: flex;
@ -24,8 +18,8 @@ form button {
margin-top: 1rem;
}
form input {
width: calc(100% - 1rem - 2px) !important;
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}

View file

@ -1,6 +1,7 @@
{{define "head"}}
<title>Audit Logs - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<link rel="stylesheet" href="/admin/static/logs.css">
{{end}}
@ -8,7 +9,7 @@
<main>
<h1>Audit Logs</h1>
<form action="/admin/logs" method="GET" id="search-form">
<form action="/admin/logs" method="GET">
<div id="search">
<input type="text" name="q" value="" placeholder="Filter by message...">
<button type="submit" class="save">Search</button>
@ -43,27 +44,25 @@
<hr>
<div id="logs">
<table>
<thead>
<tr>
<th class="log-time">Time</th>
<th class="log-level">Level</th>
<th class="log-type">Type</th>
<th class="log-content">Message</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr class="log {{toLower (parseLevel .Level)}}">
<td class="log-time">{{prettyTime .CreatedAt}}</td>
<td class="log-level">{{parseLevel .Level}}</td>
<td class="log-type">{{titleCase .Type}}</td>
<td class="log-content">{{.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<table id="logs">
<thead>
<tr>
<th class="log-time">Time</th>
<th class="log-level">Level</th>
<th class="log-type">Type</th>
<th class="log-content">Message</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr class="log {{lower (parseLevel .Level)}}">
<td class="log-time">{{prettyTime .CreatedAt}}</td>
<td class="log-level">{{parseLevel .Level}}</td>
<td class="log-type">{{titleCase .Type}}</td>
<td class="log-content">{{.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
</main>
{{end}}

View file

@ -1,6 +1,7 @@
{{define "head"}}
<title>Register - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
p a {
color: #2a67c8;

View file

@ -1,6 +1,7 @@
{{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
.qr-code {
border: 1px solid #8888;
@ -8,20 +9,11 @@
code {
user-select: all;
}
#totp-setup input {
width: 3.8em;
min-width: auto;
font-size: 32px;
font-family: 'Monaspace Argon', monospace;
text-align: center;
}
</style>
{{end}}
{{define "content"}}
<main>
<h1>Two-Factor Authentication</h1>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
@ -48,14 +40,7 @@ code {
<p><code>{{.TOTP.Secret}}</code></p>
<label for="totp">TOTP:</label>
<input type="text"
name="totp"
value=""
minlength="6"
maxlength="6"
autocomplete="one-time-code"
required
autofocus>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
<button type="submit" class="new">Create</button>
</form>

View file

@ -1,12 +1,11 @@
{{define "head"}}
<title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
{{end}}
{{define "content"}}
<main>
<h1>Two-Factor Authentication</h1>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}

View file

@ -1,14 +1,15 @@
package api
import (
"context"
"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 {
@ -172,7 +173,7 @@ func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
// check cookies first
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, fmt.Errorf("Failed to retrieve session cookie: %v\n", err)
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err))
}
if sessionCookie != nil {
token = sessionCookie.Value
@ -187,7 +188,7 @@ func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
session, err := controller.GetSession(app.DB, token)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v\n", err)
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err))
}
if session != nil {

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, r)
session, err := controller.GetSessionFromRequest(app.DB, 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\" created.", imagePath)
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,26 +110,3 @@ 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,53 +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
}
func GetArtistCount(db *sqlx.DB) (int, error) {
var count int
err := db.Get(&count, "SELECT count(*) FROM artist")
return count, err
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) {
@ -59,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 {
@ -107,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.space",
BaseUrl: "https://arimelody.me",
Host: "0.0.0.0",
Port: 8080,
TrustedProxies: []string{ "127.0.0.1" },
@ -77,9 +77,5 @@ func handleConfigOverrides(config *model.Config) error {
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_DISCORD_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

@ -5,7 +5,7 @@ import "math/rand"
func GenerateAlnumString(length int) []byte {
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
res := []byte{}
for range length {
for i := 0; i < length; i++ {
res = append(res, CHARS[rand.Intn(len(CHARS))])
}
return res

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,7 +1,6 @@
package controller
import (
"embed"
"fmt"
"os"
"time"
@ -9,7 +8,7 @@ import (
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 4
const DB_VERSION int = 3
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@ -46,23 +45,16 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "002-audit-logs")
oldDBVersion = 3
case 3:
ApplyMigration(db, "003-fail-lock")
oldDBVersion = 4
}
}
fmt.Printf("Database schema up to date.\n")
}
//go:embed "schema-migration"
var schemaFS embed.FS
func ApplyMigration(db *sqlx.DB, scriptFile string) {
fmt.Printf("Applying schema migration %s...\n", scriptFile)
bytes, err := schemaFS.ReadFile("schema-migration/" + scriptFile + ".sql")
bytes, err := os.ReadFile("schema-migration/" + scriptFile + ".sql")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
os.Exit(1)

View file

@ -1,9 +1,13 @@
package controller
import (
"encoding/base64"
// "image"
// "image/color"
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"github.com/skip2/go-qrcode"
)
@ -18,35 +22,99 @@ func GenerateQRCode(data string) (string, error) {
}
// vvv DEPRECATED vvv
// const margin = 4
//
// type QRCodeECCLevel int64
// const (
// LOW QRCodeECCLevel = iota
// MEDIUM
// QUARTILE
// HIGH
// )
//
// func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
// for yi := range 7 {
// for xi := range 7 {
// if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
// img.Set(x + xi, y + yi, color.Black)
// } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
// img.Set(x + xi, y + yi, color.Black)
// }
// }
// }
// }
//
// func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
// for yi := range 5 {
// for xi := range 5 {
// if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
// img.Set(x + xi, y + yi, color.Black)
// }
// }
// }
// img.Set(x + 2, y + 2, color.Black)
// }
const margin = 4
type QRCodeECCLevel int64
const (
LOW QRCodeECCLevel = iota
MEDIUM
QUARTILE
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 {
if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
img.Set(x + xi, y + yi, color.Black)
} else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
img.Set(x + xi, y + yi, color.Black)
}
}
}
}
func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
for yi := range 5 {
for xi := range 5 {
if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
img.Set(x + xi, y + yi, color.Black)
}
}
}
img.Set(x + 2, y + 2, color.Black)
}

View file

@ -1,11 +1,12 @@
package controller
import (
"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) {
@ -20,7 +21,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
// get credits
credits, err := GetReleaseCredits(db, id)
if err != nil {
return nil, fmt.Errorf("Credits: %s", err)
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
@ -29,7 +30,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
// get tracks
tracks, err := GetReleaseTracks(db, id)
if err != nil {
return nil, fmt.Errorf("Tracks: %s", err)
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
@ -38,7 +39,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
// get links
links, err := GetReleaseLinks(db, id)
if err != nil {
return nil, fmt.Errorf("Links: %s", err)
return nil, errors.New(fmt.Sprintf("Links: %s", err))
}
for _, link := range links {
release.Links = append(release.Links, link)
@ -70,7 +71,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
// get credits
credits, err := GetReleaseCredits(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Credits: %s", err)
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
@ -80,7 +81,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
// get tracks
tracks, err := GetReleaseTracks(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Tracks: %s", err)
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
@ -89,7 +90,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
// get links
links, err := GetReleaseLinks(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Links: %s", err)
return nil, errors.New(fmt.Sprintf("Links: %s", err))
}
for _, link := range links {
release.Links = append(release.Links, link)
@ -99,17 +100,6 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
return releases, nil
}
func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) {
query := "SELECT count(*) FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
var count int
err := db.Get(&count, query)
return count, err
}
func CreateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(

View file

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

View file

@ -1,50 +1,38 @@
package controller
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"time"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err)
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
}
var session *model.Session
if sessionCookie != nil {
// fetch existing session
session, err = GetSession(app.DB, sessionCookie.Value)
session, err = GetSession(db, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v", err)
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
}
if session != nil {
if session.UserAgent != r.UserAgent() {
msg := "Session user agent mismatch. A cookie may have been hijacked!"
if session.Account != nil {
account, _ := GetAccountByID(app.DB, session.Account.ID)
msg += " (Account \"" + account.Username + "\")"
}
app.Log.Warn(log.TYPE_ACCOUNT, msg)
err = DeleteSession(app.DB, session.Token)
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
}
return nil, nil
}
// TODO: consider running security checks here (i.e. user agent mismatches)
}
}
@ -187,7 +175,3 @@ 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.space") + ":" + url.QueryEscape(username),
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
}
query := url.Query()
query.Set("secret", secret)
query.Set("issuer", "arimelody.space")
query.Set("issuer", "arimelody.me")
// query.Set("algorithm", "SHA1")
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))

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,58 +13,53 @@ 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
}
func GetTrackCount(db *sqlx.DB) (int, error) {
var count int
err := db.Get(&count, "SELECT count(*) FROM musictrack")
return count, err
}
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 "+
@ -72,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"`
@ -119,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
}

View file

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

View file

@ -1,16 +1,16 @@
package cursor
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 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)))
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)))
}
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.space/arimelody-web:latest
image: docker.arimelody.me/arimelody.me: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 (

169
main.go
View file

@ -2,7 +2,6 @@ package main
import (
"bufio"
"embed"
"errors"
"fmt"
stdLog "log"
@ -23,6 +22,7 @@ import (
"arimelody-web/cursor"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
@ -34,18 +34,12 @@ import (
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080
const HRT_DATE int64 = 1756478697
//go:embed "public"
var publicFS embed.FS
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
app := model.AppState{
Config: controller.GetConfig(),
Twitch: nil,
PublicFS: publicFS,
}
// initialise database connection
@ -282,13 +276,11 @@ func main() {
"User: %s\n" +
"\tID: %s\n" +
"\tEmail: %s\n" +
"\tCreated: %s\n" +
"\tLocked: %t\n",
"\tCreated: %s\n",
account.Username,
account.ID,
email,
account.CreatedAt,
account.Locked,
)
}
return
@ -363,64 +355,6 @@ 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)
@ -455,10 +389,7 @@ 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 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",
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
)
return
}
@ -466,13 +397,6 @@ 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")
@ -493,13 +417,6 @@ 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 {
@ -521,7 +438,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),
CheckRequest(&app, HTTPLog(DefaultHeaders(mux))),
HTTPLog(DefaultHeaders(mux)),
))
}
@ -531,13 +448,49 @@ 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", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", view.IndexHandler(app))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.IndexTemplate.Execute(w, nil)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
staticHandler("public").ServeHTTP(w, r)
}))
return mux
}
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",
@ -568,39 +521,9 @@ 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
}
// obviously .php requests these don't affect me, but these tend to be
// lazy wordpress intrusion attempts. if that's what you're about, i
// don't want you on my site.
if 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", "ari melody webbed site")
w.Header().Add("Server", "arimelody.me")
w.Header().Add("Do-Not-Stab", "1")
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
w.Header().Add("X-Hacker", "spare me please")
@ -610,10 +533,6 @@ 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)
})
}
@ -666,6 +585,6 @@ func HTTPLog(next http.Handler) http.Handler {
lrw.Status,
colour.Reset,
elapsed,
r.Header.Get("User-Agent"))
r.Header["User-Agent"][0])
})
}

View file

@ -1,23 +1,20 @@
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"`
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
Locked bool `json:"locked" db:"locked"`
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"`
Privileges []AccountPrivilege `json:"privileges"`
}

View file

@ -1,11 +1,9 @@
package model
import (
"embed"
"github.com/jmoiron/sqlx"
"arimelody-web/log"
"arimelody-web/log"
)
type (
@ -23,12 +21,6 @@ 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"`
@ -36,15 +28,12 @@ type (
DataDirectory string `toml:"data_dir"`
TrustedProxies []string `toml:"trusted_proxies"`
DB DBConfig `toml:"db"`
Discord *DiscordConfig `toml:"discord"`
Twitch *TwitchConfig `toml:"twitch"`
Discord DiscordConfig `toml:"discord"`
}
AppState struct {
DB *sqlx.DB
Config Config
Log log.Logger
Twitch *TwitchState
PublicFS embed.FS
}
)

View file

@ -1,17 +1,21 @@
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
func (artist Artist) GetWebsite() string {
return artist.Website
}
func (artist Artist) GetAvatar() string {
if artist.Avatar == "" {
return "/img/default-avatar.png"
}
return artist.Avatar
}

View file

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

View file

@ -1,16 +1,16 @@
package model
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 strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
}

View file

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

View file

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

View file

@ -1,8 +1,8 @@
package model
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,28 +1,28 @@
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 `json:"-"`
}
Number int
}
)
func (track Track) GetDescriptionHTML() template.HTML {
return template.HTML(strings.ReplaceAll(track.Description, "\n", "<br>"))
return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1))
}
func (track Track) GetLyricsHTML() template.HTML {
return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>"))
return template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1))
}
// this function is stupid and i hate that i need it

View file

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

Before

Width:  |  Height:  |  Size: 5.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 890 B

View file

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show more