Compare commits
No commits in common. "main" and "feature/auditlogs" have entirely different histories.
main
...
feature/au
142 changed files with 1775 additions and 4490 deletions
|
|
@ -7,14 +7,14 @@ tmp_dir = "tmp"
|
||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "go build -o ./tmp/main ."
|
cmd = "go build -o ./tmp/main ."
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["uploads", "test", "db", "res"]
|
exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
exclude_regex = ["_test.go"]
|
exclude_regex = ["_test.go"]
|
||||||
exclude_unchanged = false
|
exclude_unchanged = false
|
||||||
follow_symlink = false
|
follow_symlink = false
|
||||||
full_bin = ""
|
full_bin = ""
|
||||||
include_dir = []
|
include_dir = []
|
||||||
include_ext = ["go", "tpl", "tmpl", "html", "css"]
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
include_file = []
|
include_file = []
|
||||||
kill_delay = "0s"
|
kill_delay = "0s"
|
||||||
log = "build-errors.log"
|
log = "build-errors.log"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,4 +8,3 @@ docker-compose*.yml
|
||||||
!docker-compose.example.yml
|
!docker-compose.example.yml
|
||||||
config*.toml
|
config*.toml
|
||||||
arimelody-web
|
arimelody-web
|
||||||
arimelody-web.tar.gz
|
|
||||||
|
|
|
||||||
12
Makefile
12
Makefile
|
|
@ -1,12 +0,0 @@
|
||||||
EXEC = arimelody-web
|
|
||||||
|
|
||||||
.PHONY: $(EXEC)
|
|
||||||
|
|
||||||
build:
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o $(EXEC)
|
|
||||||
|
|
||||||
bundle: build
|
|
||||||
tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm $(EXEC) $(EXEC).tar.gz
|
|
||||||
16
README.md
16
README.md
|
|
@ -1,12 +1,17 @@
|
||||||
# ari melody website
|
# arimelody.me
|
||||||
|
|
||||||
home to your local SPACEGIRL! 💫
|
home to your local SPACEGIRL! 💫
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
a slightly-overcomplicated webserver built to show off everything i've worked
|
built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static)
|
||||||
on, and then some! this server comes complete with twitch live status tracking,
|
branch, this powerful, server-side rendered version comes complete with live
|
||||||
a portfolio database, and a full-fledged admin CMS panel to manage it all!
|
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
|
## build
|
||||||
|
|
||||||
|
|
@ -42,6 +47,3 @@ need to be up for this, making this ideal for some offline maintenance.
|
||||||
- `purgeInvites`: Deletes all available invite codes.
|
- `purgeInvites`: Deletes all available invite codes.
|
||||||
- `listAccounts`: Lists all active accounts.
|
- `listAccounts`: Lists all active accounts.
|
||||||
- `deleteAccount <username>`: Deletes an account with a given `username`.
|
- `deleteAccount <username>`: Deletes an account with a given `username`.
|
||||||
- `lockAccount <username>`: Locks the account under `username`.
|
|
||||||
- `unlockAccount <username>`: Unlocks the account under `username`.
|
|
||||||
- `logs`: Shows system logs.
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"arimelody-web/admin/templates"
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
@ -18,12 +17,12 @@ import (
|
||||||
func accountHandler(app *model.AppState) http.Handler {
|
func accountHandler(app *model.AppState) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/account/totp-setup", totpSetupHandler(app))
|
mux.Handle("/totp-setup", totpSetupHandler(app))
|
||||||
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
|
mux.Handle("/totp-confirm", totpConfirmHandler(app))
|
||||||
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
|
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
|
||||||
|
|
||||||
mux.Handle("/account/password", changePasswordHandler(app))
|
mux.Handle("/password", changePasswordHandler(app))
|
||||||
mux.Handle("/account/delete", deleteAccountHandler(app))
|
mux.Handle("/delete", deleteAccountHandler(app))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +44,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
accountResponse struct {
|
accountResponse struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
TOTPs []TOTP
|
TOTPs []TOTP
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -65,8 +64,8 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
||||||
session.Message = sessionMessage
|
session.Message = sessionMessage
|
||||||
session.Error = sessionError
|
session.Error = sessionError
|
||||||
|
|
||||||
err = templates.AccountTemplate.Execute(w, accountResponse{
|
err = accountTemplate.Execute(w, accountResponse{
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
Session: session,
|
||||||
TOTPs: totps,
|
TOTPs: totps,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -116,7 +115,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(r))
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
||||||
|
|
@ -146,7 +145,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
// check password
|
// check password
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(r))
|
||||||
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
return
|
return
|
||||||
|
|
@ -160,7 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(r))
|
||||||
|
|
||||||
controller.SetSessionAccount(app.DB, session, nil)
|
controller.SetSessionAccount(app.DB, session, nil)
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
|
|
@ -170,7 +169,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
type totpConfirmData struct {
|
type totpConfirmData struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
TOTP *model.TOTP
|
TOTP *model.TOTP
|
||||||
NameEscaped string
|
NameEscaped string
|
||||||
QRBase64Image string
|
QRBase64Image string
|
||||||
|
|
@ -179,9 +178,13 @@ type totpConfirmData struct {
|
||||||
func totpSetupHandler(app *model.AppState) http.Handler {
|
func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
|
type totpSetupData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
session := r.Context().Value("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 {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
@ -218,9 +221,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{
|
err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
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)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
|
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
Session: session,
|
||||||
TOTP: &totp,
|
TOTP: &totp,
|
||||||
NameEscaped: url.PathEscape(totp.Name),
|
NameEscaped: url.PathEscape(totp.Name),
|
||||||
QRBase64Image: qrBase64Image,
|
QRBase64Image: qrBase64Image,
|
||||||
|
|
@ -266,6 +267,11 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
return
|
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)
|
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
||||||
if err != nil {
|
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)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
code := r.FormValue("totp")
|
|
||||||
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
|
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
|
||||||
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
if code != confirmCode {
|
||||||
if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
|
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
||||||
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
|
if code != confirmCodeOffset {
|
||||||
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
|
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
||||||
TOTP: totp,
|
Session: session,
|
||||||
NameEscaped: url.PathEscape(totp.Name),
|
TOTP: totp,
|
||||||
QRBase64Image: qrBase64Image,
|
NameEscaped: url.PathEscape(totp.Name),
|
||||||
})
|
QRBase64Image: qrBase64Image,
|
||||||
if err != nil {
|
})
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
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)
|
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 {
|
func totpDeleteHandler(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodGet {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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()
|
session := r.Context().Value("session").(*model.Session)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -5,82 +5,49 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/admin/templates"
|
"arimelody-web/model"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveArtists(app *model.AppState) http.Handler {
|
func serveArtist(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
slices := strings.Split(r.URL.Path[1:], "/")
|
||||||
|
id := slices[0]
|
||||||
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/")
|
artist, err := controller.GetArtist(app.DB, id)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if artist == nil {
|
if artist == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
|
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistResponse struct {
|
type ArtistResponse struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
Artist *model.Artist
|
Artist *model.Artist
|
||||||
Credits []*model.Credit
|
Credits []*model.Credit
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
|
session := r.Context().Value("session").(*model.Session)
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
|
||||||
|
err = artistTemplate.Execute(w, ArtistResponse{
|
||||||
|
Session: session,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Credits: credits,
|
Credits: credits,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
{{range $Artist := .Artists}}
|
{{range $Artist := .Artists}}
|
||||||
<li class="new-artist"
|
<li class="new-artist"
|
||||||
data-id="{{$Artist.ID}}"
|
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-target="#editcredits ul"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>
|
>
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
<h2>Editing: Credits</h2>
|
<h2>Editing: Credits</h2>
|
||||||
<a id="add-credit"
|
<a id="add-credit"
|
||||||
class="button new"
|
class="button new"
|
||||||
href="/admin/releases/{{.ID}}/addcredit"
|
href="/admin/release/{{.ID}}/addcredit"
|
||||||
hx-get="/admin/releases/{{.ID}}/addcredit"
|
hx-get="/admin/release/{{.ID}}/addcredit"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Add</a>
|
>Add</a>
|
||||||
|
|
@ -5,17 +5,17 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="release-info">
|
<div class="release-info">
|
||||||
<h3 class="release-title">
|
<h3 class="release-title">
|
||||||
<a href="/admin/releases/{{.ID}}">{{.Title}}</a>
|
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
|
||||||
<small>
|
<small>
|
||||||
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
|
<span title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
|
||||||
{{if not .Visible}}(hidden){{end}}
|
{{if not .Visible}}(hidden){{end}}
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="release-artists">{{.PrintArtists true true}}</p>
|
<p class="release-artists">{{.PrintArtists true true}}</p>
|
||||||
<p class="release-type-single">{{.ReleaseType}}
|
<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">
|
<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>
|
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="new-track"
|
<li class="new-track"
|
||||||
data-id="{{$Track.ID}}"
|
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-target="#edittracks ul"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>
|
>
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
<h2>Editing: Tracks</h2>
|
<h2>Editing: Tracks</h2>
|
||||||
<a id="add-track"
|
<a id="add-track"
|
||||||
class="button new"
|
class="button new"
|
||||||
href="/admin/releases/{{.Release.ID}}/addtrack"
|
href="/admin/release/{{.Release.ID}}/addtrack"
|
||||||
hx-get="/admin/releases/{{.Release.ID}}/addtrack"
|
hx-get="/admin/release/{{.Release.ID}}/addtrack"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
>Add</a>
|
>Add</a>
|
||||||
175
admin/http.go
175
admin/http.go
|
|
@ -6,23 +6,17 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/admin/templates"
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"arimelody-web/view"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminPageData struct {
|
|
||||||
Path string
|
|
||||||
Session *model.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -44,30 +38,15 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
mux.Handle("/register", registerAccountHandler(app))
|
mux.Handle("/register", registerAccountHandler(app))
|
||||||
|
|
||||||
mux.Handle("/account", requireAccount(accountIndexHandler(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("/logs", requireAccount(logsHandler(app)))
|
||||||
|
|
||||||
mux.Handle("/releases", requireAccount(serveReleases(app)))
|
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
|
||||||
mux.Handle("/releases/", requireAccount(serveReleases(app)))
|
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
|
||||||
mux.Handle("/artists", requireAccount(serveArtists(app)))
|
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
|
||||||
mux.Handle("/artists/", requireAccount(serveArtists(app)))
|
|
||||||
mux.Handle("/tracks", requireAccount(serveTracks(app)))
|
|
||||||
mux.Handle("/tracks/", requireAccount(serveTracks(app)))
|
|
||||||
|
|
||||||
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
||||||
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("/", requireAccount(AdminIndexHandler(app)))
|
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
|
||||||
|
|
||||||
|
|
@ -84,18 +63,12 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
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)
|
artists, err := controller.GetAllArtists(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -103,12 +76,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
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)
|
tracks, err := controller.GetOrphanTracks(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -116,31 +83,19 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
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 {
|
type IndexData struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
Releases []*model.Release
|
Releases []*model.Release
|
||||||
ReleaseCount int
|
Artists []*model.Artist
|
||||||
Artists []*model.Artist
|
Tracks []*model.Track
|
||||||
ArtistCount int
|
|
||||||
Tracks []*model.Track
|
|
||||||
TrackCount int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.IndexTemplate.Execute(w, IndexData{
|
err = indexTemplate.Execute(w, IndexData{
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
Session: session,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
ReleaseCount: releaseCount,
|
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
ArtistCount: artistCount,
|
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
TrackCount: trackCount,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type registerData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
render := func() {
|
render := func() {
|
||||||
err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
|
err := registerTemplate.Execute(w, registerData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
@ -242,7 +201,7 @@ func registerAccountHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
|
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(r))
|
||||||
|
|
||||||
err = controller.DeleteInvite(app.DB, invite.Code)
|
err = controller.DeleteInvite(app.DB, invite.Code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -266,8 +225,12 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
|
type loginData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
render := func() {
|
render := func() {
|
||||||
err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
|
err := loginTemplate.Execute(w, loginData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
@ -311,20 +274,11 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if account.Locked {
|
|
||||||
controller.SetSessionError(app.DB, session, "This account is locked.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(r))
|
||||||
if locked := handleFailedLogin(app, account, r); locked {
|
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
|
||||||
} else {
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
|
||||||
}
|
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -345,15 +299,13 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
|
||||||
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// login success!
|
// login success!
|
||||||
// TODO: log login activity to user
|
// TODO: log login activity to user
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
|
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(r))
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
|
||||||
|
|
||||||
err = controller.SetSessionAccount(app.DB, session, account)
|
err = controller.SetSessionAccount(app.DB, session, account)
|
||||||
|
|
@ -378,8 +330,12 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type loginTOTPData struct {
|
||||||
|
Session *model.Session
|
||||||
|
}
|
||||||
|
|
||||||
render := func() {
|
render := func() {
|
||||||
err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
|
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
@ -407,7 +363,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
|
||||||
totpCode := r.FormValue("totp")
|
totpCode := r.FormValue("totp")
|
||||||
|
|
||||||
if len(totpCode) != controller.TOTP_CODE_LENGTH {
|
if len(totpCode) != controller.TOTP_CODE_LENGTH {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r))
|
||||||
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
|
|
@ -421,19 +377,13 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if totpMethod == nil {
|
if totpMethod == nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r))
|
||||||
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
|
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
||||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
|
||||||
controller.SetSessionAttemptAccount(app.DB, session, nil)
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
} else {
|
|
||||||
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
|
||||||
}
|
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
|
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(r))
|
||||||
|
|
||||||
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
|
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -473,7 +423,7 @@ func logoutHandler(app *model.AppState) http.Handler {
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
|
|
||||||
err = templates.LogoutTemplate.Execute(w, nil)
|
err = logoutTemplate.Execute(w, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
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 {
|
func staticHandler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
uri := strings.TrimPrefix(r.URL.Path, "/static")
|
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
|
||||||
file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri)))
|
// does the file exist?
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is thjs a directory? (forbidden)
|
||||||
|
if info.IsDir() {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
|
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
w.Write(file)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := controller.GetSessionFromRequest(app, r)
|
session, err := controller.GetSessionFromRequest(app.DB, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
@ -546,30 +496,3 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
|
|
||||||
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(
|
|
||||||
os.Stderr,
|
|
||||||
"WARN: Failed to increment login failures for \"%s\": %v\n",
|
|
||||||
account.Username,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
app.Log.Warn(
|
|
||||||
log.TYPE_ACCOUNT,
|
|
||||||
"Failed to increment login failures for \"%s\"",
|
|
||||||
account.Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
app.Log.Warn(
|
|
||||||
log.TYPE_ACCOUNT,
|
|
||||||
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
|
|
||||||
account.Username,
|
|
||||||
model.MAX_LOGIN_FAIL_ATTEMPTS,
|
|
||||||
controller.ResolveIP(app, r),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return locked
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/admin/templates"
|
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -51,12 +50,12 @@ func logsHandler(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogsResponse struct {
|
type LogsResponse struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
Logs []*log.Log
|
Logs []*log.Log
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.LogsTemplate.Execute(w, LogsResponse{
|
err = logsTemplate.Execute(w, LogsResponse{
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
Session: session,
|
||||||
Logs: logs,
|
Logs: logs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,60 +3,17 @@ package admin
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/admin/templates"
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
"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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
slices := strings.Split(r.URL.Path[1:], "/")
|
||||||
|
|
||||||
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
|
|
||||||
releaseID := slices[0]
|
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)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(action) > 0 {
|
if len(slices) > 1 {
|
||||||
switch action {
|
switch slices[1] {
|
||||||
case "editcredits":
|
case "editcredits":
|
||||||
serveEditCredits(release).ServeHTTP(w, r)
|
serveEditCredits(release).ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|
@ -99,20 +56,16 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReleaseResponse struct {
|
type ReleaseResponse struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
Release *model.Release
|
Release *model.Release
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, track := range release.Tracks {
|
err = releaseTemplate.Execute(w, ReleaseResponse{
|
||||||
track.Number = i + 1
|
Session: session,
|
||||||
}
|
|
||||||
|
|
||||||
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
|
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
|
||||||
Release: release,
|
Release: release,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
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 {
|
func serveEditCredits(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) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := templates.EditCreditsTemplate.Execute(w, release)
|
err := editCreditsTemplate.Execute(w, release)
|
||||||
if err != nil {
|
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)
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -144,12 +97,12 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = templates.AddCreditTemplate.Execute(w, response{
|
err = addCreditTemplate.Execute(w, response{
|
||||||
ReleaseID: release.ID,
|
ReleaseID: release.ID,
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
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]
|
artistID := strings.Split(r.URL.Path, "/")[3]
|
||||||
artist, err := controller.GetArtist(app.DB, artistID)
|
artist, err := controller.GetArtist(app.DB, artistID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -170,9 +123,9 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = templates.NewCreditTemplate.Execute(w, artist)
|
err = newCreditTemplate.Execute(w, artist)
|
||||||
if err != nil {
|
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)
|
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 {
|
func serveEditLinks(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) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := templates.EditLinksTemplate.Execute(w, release)
|
err := editLinksTemplate.Execute(w, release)
|
||||||
if err != nil {
|
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)
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
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 {
|
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)
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -218,14 +177,15 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = templates.AddTrackTemplate.Execute(w, response{
|
err = addTrackTemplate.Execute(w, response{
|
||||||
ReleaseID: release.ID,
|
ReleaseID: release.ID,
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
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]
|
trackID := strings.Split(r.URL.Path, "/")[3]
|
||||||
track, err := controller.GetTrack(app.DB, trackID)
|
track, err := controller.GetTrack(app.DB, trackID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -244,10 +204,11 @@ func serveNewTrack(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = templates.NewTrackTemplate.Execute(w, track)
|
err = newTrackTemplate.Execute(w, track)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,266 +1,88 @@
|
||||||
@import url("/style/prideflag.css");
|
@import url("/style/prideflag.css");
|
||||||
@import url("/font/inter/inter.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 {
|
body {
|
||||||
width: calc(100% - 180px);
|
width: 100%;
|
||||||
height: calc(100vh - 1em);
|
height: calc(100vh - 1em);
|
||||||
|
|
||||||
margin: 0 0 0 180px;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
font-family: "Inter", sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
font-size: 16px;
|
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 {
|
nav {
|
||||||
position: fixed;
|
width: min(720px, calc(100% - 2em));
|
||||||
top: 0;
|
height: 2em;
|
||||||
left: 0;
|
margin: 1em auto;
|
||||||
width: 180px;
|
|
||||||
height: calc(100vh - 2em);
|
|
||||||
margin: 0;
|
|
||||||
padding: 1em 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
|
|
||||||
background-color: var(--bg-1);
|
background: #f8f8f8;
|
||||||
box-shadow: var(--shadow-md);
|
border-radius: 4px;
|
||||||
transition: background .1s ease-out, color .1s ease-out;
|
border: 1px solid #808080;
|
||||||
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
nav .icon {
|
nav .icon {
|
||||||
width: fit-content;
|
height: 100%;
|
||||||
height: fit-content;
|
}
|
||||||
padding: 0;
|
nav .title {
|
||||||
margin: 0 auto 1em auto;
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
margin: 0 1em 0 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
border-radius: 100%;
|
line-height: 2em;
|
||||||
box-shadow: var(--shadow-sm);
|
text-decoration: none;
|
||||||
overflow: clip;
|
|
||||||
}
|
color: inherit;
|
||||||
nav .icon img {
|
|
||||||
width: 3em;
|
|
||||||
height: 3em;
|
|
||||||
}
|
}
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
margin: 0px;
|
||||||
|
padding: 0 1em;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
color: var(--fg-2);
|
|
||||||
line-height: 2em;
|
line-height: 2em;
|
||||||
font-weight: 500;
|
|
||||||
transition: color .1s ease-out, background-color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
color: var(--bg-2);
|
background: #00000010;
|
||||||
background-color: var(--fg-2);
|
|
||||||
text-decoration: none;
|
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 {
|
nav a {
|
||||||
padding: .2em 1em;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
nav a.active {
|
nav #logout {
|
||||||
border-left: 5px solid var(--fg-0);
|
/* margin-left: auto; */
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
width: 720px;
|
width: min(720px, calc(100% - 2em));
|
||||||
max-width: calc(100% - 2em);
|
|
||||||
height: fit-content;
|
|
||||||
min-height: calc(100vh - 2em);
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
main.dashboard {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: color .1s ease-out, background-color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
img.icon {
|
a img.icon {
|
||||||
height: .8em;
|
height: .8em;
|
||||||
transition: filter .1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
|
|
@ -272,24 +94,7 @@ code {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.cards {
|
|
||||||
width: 100%;
|
|
||||||
height: fit-content;
|
|
||||||
display: flex;
|
|
||||||
gap: 2em;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.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;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,7 +102,7 @@ main:not(.dashboard) .card {
|
||||||
margin: 0 0 .5em 0;
|
margin: 0 0 .5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-title {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
|
|
@ -305,31 +110,21 @@ main:not(.dashboard) .card {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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-title h1,
|
||||||
.card-header :is(h1, h2, h3) small {
|
.card-title h2,
|
||||||
display: inline-block;
|
.card-title h3 {
|
||||||
font-size: .6em;
|
margin: 0;
|
||||||
transform: translateY(-0.1em);
|
|
||||||
color: var(--fg-0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-fill {
|
.flex-fill {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artists-group {
|
@media screen and (max-width: 520px) {
|
||||||
display: grid;
|
body {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
font-size: 12px;
|
||||||
gap: 1em;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -338,15 +133,17 @@ header :is(h1, h2, h3) small,
|
||||||
#error {
|
#error {
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
color: #101010;
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
border: 1px solid #888;
|
||||||
}
|
}
|
||||||
#message {
|
#message {
|
||||||
background: #a9dfff;
|
background: #a9dfff;
|
||||||
|
border-color: #599fdc;
|
||||||
}
|
}
|
||||||
#error {
|
#error {
|
||||||
background: #ffa9b8;
|
background: #ffa9b8;
|
||||||
|
border-color: #dc5959;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -355,54 +152,52 @@ a.delete:not(.button) {
|
||||||
color: #d22828;
|
color: #d22828;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button, button {
|
button, .button {
|
||||||
padding: .5em .8em;
|
padding: .5em .8em;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #a0a0a0;
|
||||||
|
background: #f0f0f0;
|
||||||
color: inherit;
|
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 {
|
button:hover, .button:hover {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
border-color: #d0d0d0;
|
||||||
}
|
}
|
||||||
button:active, .button:active {
|
button:active, .button:active {
|
||||||
background: #d0d0d0;
|
background: #d0d0d0;
|
||||||
|
border-color: #808080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button, button {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
.button.new, button.new {
|
.button.new, button.new {
|
||||||
color: var(--col-on-new);
|
background: #c4ff6a;
|
||||||
background: var(--col-new);
|
border-color: #84b141;
|
||||||
}
|
}
|
||||||
.button.save, button.save {
|
.button.save, button.save {
|
||||||
color: var(--col-on-save);
|
background: #6fd7ff;
|
||||||
background: var(--col-save);
|
border-color: #6f9eb0;
|
||||||
}
|
}
|
||||||
.button.delete, button.delete {
|
.button.delete, button.delete {
|
||||||
color: var(--col-on-delete);
|
background: #ff7171;
|
||||||
background: var(--col-delete);
|
border-color: #7d3535;
|
||||||
}
|
}
|
||||||
.button:hover, button:hover {
|
.button:hover, button:hover {
|
||||||
color: var(--bg-3);
|
background: #fff;
|
||||||
background: var(--fg-3);
|
border-color: #d0d0d0;
|
||||||
}
|
}
|
||||||
.button:active, button:active {
|
.button:active, button:active {
|
||||||
color: var(--bg-2);
|
background: #d0d0d0;
|
||||||
background: var(--fg-0);
|
border-color: #808080;
|
||||||
}
|
}
|
||||||
.button[disabled], button[disabled] {
|
.button[disabled], button[disabled] {
|
||||||
color: var(--fg-0) !important;
|
background: #d0d0d0 !important;
|
||||||
background: var(--bg-3) !important;
|
border-color: #808080 !important;
|
||||||
opacity: .5;
|
opacity: .5;
|
||||||
cursor: default !important;
|
cursor: not-allowed !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -410,79 +205,24 @@ button:active, .button:active {
|
||||||
form {
|
form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--fg-0);
|
|
||||||
}
|
}
|
||||||
form label {
|
form label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 1rem 0 .5rem 0;
|
margin: 1rem 0 .5rem 0;
|
||||||
display: block;
|
display: block;
|
||||||
|
color: #10101080;
|
||||||
}
|
}
|
||||||
form input[type="text"],
|
form input {
|
||||||
form input[type="password"] {
|
margin: .5rem 0;
|
||||||
width: 16em;
|
padding: .3rem .5rem;
|
||||||
max-width: 100%;
|
|
||||||
margin: .5em 0;
|
|
||||||
padding: .3em .5em;
|
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #808080;
|
border: 1px solid #808080;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background-color: var(--bg-0);
|
|
||||||
}
|
}
|
||||||
input[disabled] {
|
input[disabled] {
|
||||||
opacity: .5;
|
opacity: .5;
|
||||||
cursor: not-allowed;
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -69,28 +69,3 @@ export function makeMagicList(container, itemSelector, callback) {
|
||||||
if (callback) 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");
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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"))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -11,11 +11,8 @@ label {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
form#change-password input,
|
input {
|
||||||
form#delete-account input {
|
width: min(20rem, calc(100% - 1rem));
|
||||||
width: 20em;
|
|
||||||
min-width: auto;
|
|
||||||
max-width: calc(100% - 1em - 2px);
|
|
||||||
margin: .5rem 0;
|
margin: .5rem 0;
|
||||||
padding: .3rem .5rem;
|
padding: .3rem .5rem;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -28,14 +25,12 @@ form#delete-account input {
|
||||||
|
|
||||||
.mfa-device {
|
.mfa-device {
|
||||||
padding: .75em;
|
padding: .75em;
|
||||||
|
background: #f8f8f8f8;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
border-radius: 8px;
|
||||||
margin-bottom: .5em;
|
margin-bottom: .5em;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
color: var(--fg-3);
|
|
||||||
background: var(--bg-2);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mfa-device div {
|
.mfa-device div {
|
||||||
|
|
@ -51,14 +46,3 @@ form#delete-account input {
|
||||||
.mfa-device .mfa-device-name {
|
.mfa-device .mfa-device-name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mfa-device form input {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mfa-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: .5em;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
#artist {
|
#artist {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
padding: 1.5em;
|
padding: 1.5em;
|
||||||
|
|
@ -5,9 +9,9 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1.2em;
|
gap: 1.2em;
|
||||||
|
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
background: var(--bg-2);
|
background: #f8f8f8f8;
|
||||||
box-shadow: var(--shadow-md);
|
border: 1px solid #808080;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-avatar {
|
.artist-avatar {
|
||||||
|
|
@ -23,8 +27,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.artist-avatar #remove-avatar {
|
.artist-avatar #remove-avatar {
|
||||||
margin-top: .5em;
|
padding: .3em .4em;
|
||||||
padding: .3em .6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-info {
|
.artist-info {
|
||||||
|
|
@ -50,8 +53,8 @@ input[type="text"] {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--bg-0);
|
background: #ffffff;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +74,7 @@ input[type="text"]:focus {
|
||||||
justify-content: right;
|
justify-content: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header a.button {
|
.card-title a.button {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,17 +85,9 @@ input[type="text"]:focus {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
background: #f8f8f8;
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
background: var(--bg-2);
|
border: 1px solid #808080;
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit:hover {
|
|
||||||
background: var(--bg-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-artwork {
|
.release-artwork {
|
||||||
|
|
@ -101,14 +96,8 @@ input[type="text"]:focus {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.credit-info {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.credit-info h3,
|
.credit-info h3,
|
||||||
.credit-info p {
|
.credit-info p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: .9em;
|
font-size: .9em;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { hijackClickEvent } from "./admin.js";
|
|
||||||
|
|
||||||
const artistID = document.getElementById("artist").dataset.id;
|
const artistID = document.getElementById("artist").dataset.id;
|
||||||
const nameInput = document.getElementById("name");
|
const nameInput = document.getElementById("name");
|
||||||
const avatarImg = document.getElementById("avatar");
|
const avatarImg = document.getElementById("avatar");
|
||||||
|
|
@ -79,9 +77,3 @@ removeAvatarBtn.addEventListener("click", () => {
|
||||||
avatarImg.src = "/img/default-avatar.png"
|
avatarImg.src = "/img/default-avatar.png"
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('readystatechange', () => {
|
|
||||||
document.querySelectorAll('#releases .credit').forEach(el => {
|
|
||||||
hijackClickEvent(el, el.querySelector('.credit-name a'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,8 @@ input[type="text"] {
|
||||||
gap: 1.2em;
|
gap: 1.2em;
|
||||||
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-2);
|
background: #f8f8f8f8;
|
||||||
box-shadow: var(--shadow-md);
|
border: 1px solid #808080;
|
||||||
|
|
||||||
transition: background .1s ease-out, color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-artwork {
|
.release-artwork {
|
||||||
|
|
@ -31,9 +29,7 @@ input[type="text"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.release-artwork #remove-artwork {
|
.release-artwork #remove-artwork {
|
||||||
margin-top: .5em;
|
padding: .3em .4em;
|
||||||
padding: .3em .6em;
|
|
||||||
background: var(--bg-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-info {
|
.release-info {
|
||||||
|
|
@ -58,17 +54,17 @@ input[type="text"] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .1s ease-out, border-color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#title:hover {
|
#title:hover {
|
||||||
background: var(--bg-3);
|
background: #ffffff;
|
||||||
border-color: var(--fg-0);
|
border-color: #80808080;
|
||||||
}
|
}
|
||||||
|
|
||||||
#title:active,
|
#title:active,
|
||||||
#title:focus {
|
#title:focus {
|
||||||
background: var(--bg-3);
|
background: #ffffff;
|
||||||
|
border-color: #808080;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-title small {
|
.release-title small {
|
||||||
|
|
@ -79,21 +75,19 @@ input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: .5em 0;
|
margin: .5em 0;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
color: var(--fg-2);
|
|
||||||
}
|
}
|
||||||
.release-info table td {
|
.release-info table td {
|
||||||
padding: .2em;
|
padding: .2em;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--fg-0) 25%, transparent);
|
border-bottom: 1px solid #d0d0d0;
|
||||||
transition: background .1s ease-out, border-color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
.release-info table tr td:first-child {
|
.release-info table tr td:first-child {
|
||||||
vertical-align: top;
|
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) select:hover,
|
||||||
.release-info table tr td:not(:first-child) input:hover,
|
.release-info table tr td:not(:first-child) input:hover,
|
||||||
.release-info table tr td:not(:first-child) textarea:hover {
|
.release-info table tr td:not(:first-child) textarea:hover {
|
||||||
background: var(--bg-3);
|
background: #e8e8e8;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.release-info table td select,
|
.release-info table td select,
|
||||||
|
|
@ -121,25 +115,13 @@ input[type="text"] {
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: right;
|
justify-content: right;
|
||||||
color: var(--fg-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.release-actions button,
|
|
||||||
.release-actions .button {
|
|
||||||
color: var(--fg-2);
|
|
||||||
background: var(--bg-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
width: min(720px, calc(100% - 2em));
|
width: min(720px, calc(100% - 2em));
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
border: none;
|
border: 1px solid #101010;
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
color: var(--fg-0);
|
|
||||||
background-color: var(--bg-0);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
|
|
||||||
transition: color .1s ease-out, background-color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog header {
|
dialog header {
|
||||||
|
|
@ -162,7 +144,7 @@ dialog div.dialog-actions {
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header a.button {
|
.card-title a.button {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +152,7 @@ dialog div.dialog-actions {
|
||||||
* RELEASE CREDITS
|
* RELEASE CREDITS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#credits .credit {
|
.card.credits .credit {
|
||||||
margin-bottom: .5em;
|
margin-bottom: .5em;
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -178,47 +160,28 @@ dialog div.dialog-actions {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
|
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
background-color: var(--bg-2);
|
background: #f8f8f8f8;
|
||||||
box-shadow: var(--shadow-md);
|
border: 1px solid #808080;
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .1s ease-out;
|
|
||||||
}
|
|
||||||
#credits .credit:hover {
|
|
||||||
background-color: var(--bg-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#credits .credit p {
|
.card.credits .credit p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#credits .credit .artist-avatar {
|
.card.credits .credit .artist-avatar {
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#credits .credit .artist-name {
|
.card.credits .credit .artist-name {
|
||||||
color: var(--fg-3);
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#credits .credit .artist-role small {
|
.card.credits .credit .artist-role small {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
opacity: .66;
|
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 {
|
#editcredits ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -234,8 +197,8 @@ dialog div.dialog-actions {
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-2);
|
background: #f8f8f8f8;
|
||||||
box-shadow: var(--shadow-md);
|
border: 1px solid #808080;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editcredits .credit {
|
#editcredits .credit {
|
||||||
|
|
@ -269,7 +232,6 @@ dialog div.dialog-actions {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
|
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
|
||||||
|
|
@ -277,17 +239,15 @@ dialog div.dialog-actions {
|
||||||
padding: .2em .4em;
|
padding: .2em .4em;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
border: none;
|
border: 1px solid #8888;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--fg-2);
|
color: inherit;
|
||||||
background: var(--bg-0);
|
|
||||||
}
|
}
|
||||||
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
|
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
|
||||||
margin: 0 .3em;
|
margin: 0 .3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editcredits .credit .artist-name {
|
#editcredits .credit .artist-name {
|
||||||
color: var(--fg-2);
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,12 +256,8 @@ dialog div.dialog-actions {
|
||||||
opacity: .66;
|
opacity: .66;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editcredits .credit .delete {
|
#editcredits .credit button.delete {
|
||||||
margin-right: .5em;
|
margin-left: auto;
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#editcredits .credit .delete:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#addcredit ul {
|
#addcredit ul {
|
||||||
|
|
@ -333,39 +289,24 @@ dialog div.dialog-actions {
|
||||||
* RELEASE LINKS
|
* RELEASE LINKS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#links ul {
|
.card.links {
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .2em;
|
gap: .2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#links a img.icon {
|
.card.links a.button[data-name="spotify"] {
|
||||||
-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;
|
|
||||||
background-color: #8cff83
|
background-color: #8cff83
|
||||||
}
|
}
|
||||||
|
|
||||||
#links a.button[data-name="apple music"] {
|
.card.links a.button[data-name="apple music"] {
|
||||||
color: #101010;
|
|
||||||
background-color: #8cd9ff
|
background-color: #8cd9ff
|
||||||
}
|
}
|
||||||
|
|
||||||
#links a.button[data-name="soundcloud"] {
|
.card.links a.button[data-name="soundcloud"] {
|
||||||
color: #101010;
|
|
||||||
background-color: #fdaa6d
|
background-color: #fdaa6d
|
||||||
}
|
}
|
||||||
|
|
||||||
#links a.button[data-name="youtube"] {
|
.card.links a.button[data-name="youtube"] {
|
||||||
color: #101010;
|
|
||||||
background-color: #ff6e6e
|
background-color: #ff6e6e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,6 +389,62 @@ dialog div.dialog-actions {
|
||||||
outline: 1px solid #808080;
|
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 {
|
#edittracks ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -499,17 +496,15 @@ dialog div.dialog-actions {
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
background-color: var(--bg-0);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color .1s ease-out, color .1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#addtrack ul li.new-track:nth-child(even) {
|
#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 {
|
#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) {
|
@media only screen and (max-width: 1105px) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { hijackClickEvent } from "./admin.js";
|
|
||||||
|
|
||||||
const releaseID = document.getElementById("release").dataset.id;
|
const releaseID = document.getElementById("release").dataset.id;
|
||||||
const titleInput = document.getElementById("title");
|
const titleInput = document.getElementById("title");
|
||||||
const artworkImg = document.getElementById("artwork");
|
const artworkImg = document.getElementById("artwork");
|
||||||
|
|
@ -98,9 +96,3 @@ removeArtworkBtn.addEventListener("click", () => {
|
||||||
artworkData = "";
|
artworkData = "";
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("readystatechange", () => {
|
|
||||||
document.querySelectorAll("#credits .credit").forEach(el => {
|
|
||||||
hijackClickEvent(el, el.querySelector(".artist-name a"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
@import url("/admin/static/release-list-item.css");
|
@import url("/admin/static/release-list-item.css");
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 .5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
#track {
|
#track {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
padding: .5em 1.5em 1.5em 1.5em;
|
padding: .5em 1.5em 1.5em 1.5em;
|
||||||
|
|
@ -7,9 +11,9 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1.2em;
|
gap: 1.2em;
|
||||||
|
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
background: var(--bg-2);
|
background: #f8f8f8f8;
|
||||||
box-shadow: var(--shadow-md);
|
border: 1px solid #808080;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
|
|
@ -45,8 +49,7 @@
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
background: var(--bg-0);
|
border: 1px solid transparent;
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
|
||||||
82
admin/static/index.css
Normal file
82
admin/static/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({id})
|
body: JSON.stringify({id})
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (res.ok) location = "/admin/releases/" + id;
|
if (res.ok) location = "/admin/release/" + id;
|
||||||
else {
|
else {
|
||||||
res.text().then(err => {
|
res.text().then(err => {
|
||||||
alert("Request failed: " + err);
|
alert("Request failed: " + err);
|
||||||
|
|
@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => {
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
res.text().then(text => {
|
res.text().then(text => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location = "/admin/artists/" + id;
|
location = "/admin/artist/" + id;
|
||||||
} else {
|
} else {
|
||||||
alert("Request failed: " + text);
|
alert("Request failed: " + text);
|
||||||
console.error(text);
|
console.error(text);
|
||||||
|
|
@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => {
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
res.text().then(text => {
|
res.text().then(text => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location = "/admin/tracks/" + text;
|
location = "/admin/track/" + text;
|
||||||
} else {
|
} else {
|
||||||
alert("Request failed: " + text);
|
alert("Request failed: " + text);
|
||||||
console.error(text);
|
console.error(text);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,8 @@ main {
|
||||||
width: min(1080px, calc(100% - 2em))!important
|
width: min(1080px, calc(100% - 2em))!important
|
||||||
}
|
}
|
||||||
|
|
||||||
form#search-form {
|
form {
|
||||||
width: calc(100% - 2em);
|
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
padding: 1em;
|
|
||||||
border-radius: 16px;
|
|
||||||
color: var(--fg-0);
|
|
||||||
background: var(--bg-2);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div#search {
|
div#search {
|
||||||
|
|
@ -18,25 +12,24 @@ div#search {
|
||||||
|
|
||||||
#search input {
|
#search input {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: .3em .8em;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
border: none;
|
|
||||||
border-radius: 16px;
|
border-right: none;
|
||||||
color: var(--fg-1);
|
border-top-right-radius: 0;
|
||||||
background: var(--bg-0);
|
border-bottom-right-radius: 0;
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#search button {
|
#search button {
|
||||||
margin-left: .5em;
|
padding: 0 .5em;
|
||||||
padding: 0 .8em;
|
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
form #filters p {
|
form #filters p {
|
||||||
margin: .5em 0 0 0;
|
margin: .5em 0 0 0;
|
||||||
}
|
}
|
||||||
form #filters label {
|
form #filters label {
|
||||||
color: inherit;
|
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
form #filters input {
|
form #filters input {
|
||||||
|
|
@ -46,15 +39,6 @@ form #filters input {
|
||||||
|
|
||||||
#logs {
|
#logs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: scroll;
|
|
||||||
}
|
|
||||||
@media screen and (max-width: 720px) {
|
|
||||||
#logs {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#logs table{
|
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,10 +57,6 @@ form #filters input {
|
||||||
padding: .4em .8em;
|
padding: .4em .8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logs .log {
|
|
||||||
color: var(--fg-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
td, th {
|
td, th {
|
||||||
width: 1%;
|
width: 1%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -91,17 +71,15 @@ th.log-type {
|
||||||
td.log-content,
|
td.log-content,
|
||||||
td.log-content {
|
td.log-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
white-space: collapse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#logs .log:hover {
|
.log:hover {
|
||||||
background: color-mix(in srgb, var(--fg-3) 10%, transparent);
|
background: #fff8;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logs .log.warn {
|
.log.warn {
|
||||||
color: var(--col-on-warn);
|
background: #ffe86a;
|
||||||
background: var(--col-warn);
|
|
||||||
}
|
}
|
||||||
#logs .log.warn:hover {
|
.log.warn:hover {
|
||||||
background: var(--col-warn-hover);
|
background: #ffec81;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
admin/static/release-list-item.css
Normal file
87
admin/static/release-list-item.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
125
admin/templates.go
Normal 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"),
|
||||||
|
))
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
@ -5,53 +5,17 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/admin/templates"
|
"arimelody-web/model"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveTracks(app *model.AppState) http.Handler {
|
func serveTrack(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
slices := strings.Split(r.URL.Path[1:], "/")
|
||||||
|
id := slices[0]
|
||||||
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/")
|
track, err := controller.GetTrack(app.DB, id)
|
||||||
trackID := slices[0]
|
|
||||||
|
|
||||||
if len(trackID) > 0 {
|
|
||||||
serveTrack(app, trackID).ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks, err := controller.GetAllTracks(app.DB)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to fetch tracks: %s\n", 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 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)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -62,25 +26,28 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
|
||||||
|
|
||||||
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
|
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
adminPageData
|
Session *model.Session
|
||||||
Track *model.Track
|
Track *model.Track
|
||||||
Releases []*model.Release
|
Releases []*model.Release
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.EditTrackTemplate.Execute(w, TrackResponse{
|
session := r.Context().Value("session").(*model.Session)
|
||||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
|
||||||
|
err = trackTemplate.Execute(w, TrackResponse{
|
||||||
|
Session: session,
|
||||||
Track: track,
|
Track: track,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<h1>Account Settings ({{.Session.Account.Username}})</h1>
|
<h1>Account Settings ({{.Session.Account.Username}})</h1>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
|
||||||
<h2>Change Password</h2>
|
|
||||||
</div>
|
|
||||||
<form action="/admin/account/password" method="POST" id="change-password">
|
<form action="/admin/account/password" method="POST" id="change-password">
|
||||||
<label for="current-password">Current Password</label>
|
<label for="current-password">Current Password</label>
|
||||||
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
|
<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>
|
<label for="confirm-password">Confirm Password</label>
|
||||||
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
|
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<button type="submit" class="save">Change Password</button>
|
<button type="submit" class="save">Change Password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>MFA Devices</h2>
|
||||||
|
</div>
|
||||||
<div class="card mfa-devices">
|
<div class="card mfa-devices">
|
||||||
<div class="card-header">
|
|
||||||
<h2>MFA Devices</h2>
|
|
||||||
</div>
|
|
||||||
{{if .TOTPs}}
|
{{if .TOTPs}}
|
||||||
{{range .TOTPs}}
|
{{range .TOTPs}}
|
||||||
<div class="mfa-device">
|
<div class="mfa-device">
|
||||||
|
|
@ -46,10 +44,7 @@
|
||||||
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
|
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<form method="POST" action="/admin/account/totp-delete">
|
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
|
||||||
<input type="text" name="totp-name" value="{{.TOTP.Name}}" hidden>
|
|
||||||
<button type="submit" class="delete">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -57,30 +52,28 @@
|
||||||
<p>You have no MFA devices.</p>
|
<p>You have no MFA devices.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="mfa-actions">
|
<div>
|
||||||
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
|
<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>
|
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Danger Zone</h2>
|
||||||
|
</div>
|
||||||
<div class="card danger">
|
<div class="card danger">
|
||||||
<div class="card-header">
|
|
||||||
<h2>Danger Zone</h2>
|
|
||||||
</div>
|
|
||||||
<p>
|
<p>
|
||||||
Clicking the button below will delete your account.
|
Clicking the button below will delete your account.
|
||||||
This action is <strong>irreversible</strong>.
|
This action is <strong>irreversible</strong>.
|
||||||
You will need to enter your password and TOTP below.
|
You will need to enter your password and TOTP below.
|
||||||
</p>
|
</p>
|
||||||
<form action="/admin/account/delete" method="POST" id="delete-account">
|
<form action="/admin/account/delete" method="POST">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" name="password" value="" autocomplete="current-password" required>
|
<input type="password" name="password" value="" autocomplete="current-password" required>
|
||||||
|
|
||||||
<label for="totp">TOTP</label>
|
<label for="totp">TOTP</label>
|
||||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
|
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<button type="submit" class="delete">Delete Account</button>
|
<button type="submit" class="delete">Delete Account</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
|
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
|
<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/edit-artist.css">
|
||||||
<link rel="stylesheet" href="/admin/static/artists.css">
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
|
@ -30,16 +29,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="releases">
|
<div class="card-title">
|
||||||
<div class="card-header">
|
<h2>Featured in</h2>
|
||||||
<h2>Featured in</h2>
|
</div>
|
||||||
</div>
|
<div class="card releases">
|
||||||
{{if .Credits}}
|
{{if .Credits}}
|
||||||
{{range .Credits}}
|
{{range .Credits}}
|
||||||
<div class="credit">
|
<div class="credit">
|
||||||
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
||||||
<div class="credit-info">
|
<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="credit-artists">{{.Release.PrintArtists true true}}</p>
|
||||||
<p class="artist-role">
|
<p class="artist-role">
|
||||||
Role: {{.Role}}
|
Role: {{.Role}}
|
||||||
|
|
@ -55,10 +54,10 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="danger">
|
<div class="card-title">
|
||||||
<div class="card-header">
|
<h2>Danger Zone</h2>
|
||||||
<h2>Danger Zone</h2>
|
</div>
|
||||||
</div>
|
<div class="card danger">
|
||||||
<p>
|
<p>
|
||||||
Clicking the button below will delete this artist.
|
Clicking the button below will delete this artist.
|
||||||
This action is <strong>irreversible</strong>.
|
This action is <strong>irreversible</strong>.
|
||||||
|
|
@ -2,13 +2,10 @@
|
||||||
<title>Editing {{.Release.Title}} - ari melody 💫</title>
|
<title>Editing {{.Release.Title}} - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
|
<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/edit-release.css">
|
||||||
<link rel="stylesheet" href="/admin/static/releases.css">
|
|
||||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
<h1>Editing {{.Release.Title}}</h1>
|
|
||||||
|
|
||||||
<div id="release" data-id="{{.Release.ID}}">
|
<div id="release" data-id="{{.Release.ID}}">
|
||||||
<div class="release-artwork">
|
<div class="release-artwork">
|
||||||
|
|
@ -100,21 +97,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="credits">
|
<div class="card-title">
|
||||||
<div class="card-header">
|
<h2>Credits ({{len .Release.Credits}})</h2>
|
||||||
<h2>Credits <small>({{len .Release.Credits}} total)</small></h2>
|
<a class="button edit"
|
||||||
<a class="button edit"
|
href="/admin/release/{{.Release.ID}}/editcredits"
|
||||||
href="/admin/releases/{{.Release.ID}}/editcredits"
|
hx-get="/admin/release/{{.Release.ID}}/editcredits"
|
||||||
hx-get="/admin/releases/{{.Release.ID}}/editcredits"
|
hx-target="body"
|
||||||
hx-target="body"
|
hx-swap="beforeend"
|
||||||
hx-swap="beforeend"
|
>Edit</a>
|
||||||
>Edit</a>
|
</div>
|
||||||
</div>
|
<div class="card credits">
|
||||||
{{range .Release.Credits}}
|
{{range .Release.Credits}}
|
||||||
<div class="credit">
|
<div class="credit">
|
||||||
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||||
<div class="credit-info">
|
<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">
|
<p class="artist-role">
|
||||||
{{.Role}}
|
{{.Role}}
|
||||||
{{if .Primary}}
|
{{if .Primary}}
|
||||||
|
|
@ -129,42 +126,59 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="links">
|
<div class="card-title">
|
||||||
<div class="card-header">
|
<h2>Links ({{len .Release.Links}})</h2>
|
||||||
<h2>Links ({{len .Release.Links}})</h2>
|
<a class="button edit"
|
||||||
<a class="button edit"
|
href="/admin/release/{{.Release.ID}}/editlinks"
|
||||||
href="/admin/releases/{{.Release.ID}}/editlinks"
|
hx-get="/admin/release/{{.Release.ID}}/editlinks"
|
||||||
hx-get="/admin/releases/{{.Release.ID}}/editlinks"
|
hx-target="body"
|
||||||
hx-target="body"
|
hx-swap="beforeend"
|
||||||
hx-swap="beforeend"
|
>Edit</a>
|
||||||
>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>
|
</div>
|
||||||
|
<div class="card links">
|
||||||
<div class="card" id="tracks">
|
{{range .Release.Links}}
|
||||||
<div class="card-header" id="tracks">
|
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
|
||||||
<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}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="danger">
|
<div class="card-title" id="tracks">
|
||||||
<div class="card-header">
|
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
||||||
<h2>Danger Zone</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>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Danger Zone</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card danger">
|
||||||
<p>
|
<p>
|
||||||
Clicking the button below will delete this release.
|
Clicking the button below will delete this release.
|
||||||
This action is <strong>irreversible</strong>.
|
This action is <strong>irreversible</strong>.
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
<title>Editing Track - ari melody 💫</title>
|
<title>Editing Track - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<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/edit-track.css">
|
||||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
|
||||||
<link rel="stylesheet" href="/admin/static/releases.css">
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
|
@ -41,10 +39,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Featured in</h2>
|
||||||
|
</div>
|
||||||
<div class="card releases">
|
<div class="card releases">
|
||||||
<div class="card-header">
|
|
||||||
<h2>Featured in</h2>
|
|
||||||
</div>
|
|
||||||
{{if .Releases}}
|
{{if .Releases}}
|
||||||
{{range .Releases}}
|
{{range .Releases}}
|
||||||
{{block "release" .}}{{end}}
|
{{block "release" .}}{{end}}
|
||||||
|
|
@ -54,10 +52,10 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Danger Zone</h2>
|
||||||
|
</div>
|
||||||
<div class="card danger">
|
<div class="card danger">
|
||||||
<div class="card-header">
|
|
||||||
<h2>Danger Zone</h2>
|
|
||||||
</div>
|
|
||||||
<p>
|
<p>
|
||||||
Clicking the button below will delete this track.
|
Clicking the button below will delete this track.
|
||||||
This action is <strong>irreversible</strong>.
|
This action is <strong>irreversible</strong>.
|
||||||
72
admin/views/index.html
Normal file
72
admin/views/index.html
Normal 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
54
admin/views/layout.html
Normal 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>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>Login - ari melody 💫</title>
|
<title>Login - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||||
<style>
|
<style>
|
||||||
form#login-totp {
|
form#login-totp {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -17,15 +18,8 @@ form button {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
form input {
|
input {
|
||||||
width: calc(100% - 1rem - 2px) !important;
|
width: calc(100% - 1rem - 2px);
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 720px) {
|
|
||||||
h1 {
|
|
||||||
margin-top: 3em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>Login - ari melody 💫</title>
|
<title>Login - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||||
<style>
|
<style>
|
||||||
@media screen and (max-width: 720px) {
|
|
||||||
h1 {
|
|
||||||
margin-top: 3em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form#login {
|
form#login {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -24,8 +18,8 @@ form button {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
form input {
|
input {
|
||||||
width: calc(100% - 1rem - 2px) !important;
|
width: calc(100% - 1rem - 2px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>Audit Logs - ari melody 💫</title>
|
<title>Audit Logs - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<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">
|
<link rel="stylesheet" href="/admin/static/logs.css">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
@ -8,7 +9,7 @@
|
||||||
<main>
|
<main>
|
||||||
<h1>Audit Logs</h1>
|
<h1>Audit Logs</h1>
|
||||||
|
|
||||||
<form action="/admin/logs" method="GET" id="search-form">
|
<form action="/admin/logs" method="GET">
|
||||||
<div id="search">
|
<div id="search">
|
||||||
<input type="text" name="q" value="" placeholder="Filter by message...">
|
<input type="text" name="q" value="" placeholder="Filter by message...">
|
||||||
<button type="submit" class="save">Search</button>
|
<button type="submit" class="save">Search</button>
|
||||||
|
|
@ -43,27 +44,25 @@
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div id="logs">
|
<table id="logs">
|
||||||
<table>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th class="log-time">Time</th>
|
||||||
<th class="log-time">Time</th>
|
<th class="log-level">Level</th>
|
||||||
<th class="log-level">Level</th>
|
<th class="log-type">Type</th>
|
||||||
<th class="log-type">Type</th>
|
<th class="log-content">Message</th>
|
||||||
<th class="log-content">Message</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{{range .Logs}}
|
||||||
{{range .Logs}}
|
<tr class="log {{lower (parseLevel .Level)}}">
|
||||||
<tr class="log {{toLower (parseLevel .Level)}}">
|
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
||||||
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
<td class="log-level">{{parseLevel .Level}}</td>
|
||||||
<td class="log-level">{{parseLevel .Level}}</td>
|
<td class="log-type">{{titleCase .Type}}</td>
|
||||||
<td class="log-type">{{titleCase .Type}}</td>
|
<td class="log-content">{{.Content}}</td>
|
||||||
<td class="log-content">{{.Content}}</td>
|
</tr>
|
||||||
</tr>
|
{{end}}
|
||||||
{{end}}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>Register - ari melody 💫</title>
|
<title>Register - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||||
<style>
|
<style>
|
||||||
p a {
|
p a {
|
||||||
color: #2a67c8;
|
color: #2a67c8;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>TOTP Confirmation - ari melody 💫</title>
|
<title>TOTP Confirmation - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||||
<style>
|
<style>
|
||||||
.qr-code {
|
.qr-code {
|
||||||
border: 1px solid #8888;
|
border: 1px solid #8888;
|
||||||
|
|
@ -8,20 +9,11 @@
|
||||||
code {
|
code {
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
#totp-setup input {
|
|
||||||
width: 3.8em;
|
|
||||||
min-width: auto;
|
|
||||||
font-size: 32px;
|
|
||||||
font-family: 'Monaspace Argon', monospace;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
<h1>Two-Factor Authentication</h1>
|
|
||||||
|
|
||||||
{{if .Session.Error.Valid}}
|
{{if .Session.Error.Valid}}
|
||||||
<p id="error">{{html .Session.Error.String}}</p>
|
<p id="error">{{html .Session.Error.String}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -48,14 +40,7 @@ code {
|
||||||
<p><code>{{.TOTP.Secret}}</code></p>
|
<p><code>{{.TOTP.Secret}}</code></p>
|
||||||
|
|
||||||
<label for="totp">TOTP:</label>
|
<label for="totp">TOTP:</label>
|
||||||
<input type="text"
|
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
||||||
name="totp"
|
|
||||||
value=""
|
|
||||||
minlength="6"
|
|
||||||
maxlength="6"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
required
|
|
||||||
autofocus>
|
|
||||||
|
|
||||||
<button type="submit" class="new">Create</button>
|
<button type="submit" class="new">Create</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>TOTP Setup - ari melody 💫</title>
|
<title>TOTP Setup - ari melody 💫</title>
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
<h1>Two-Factor Authentication</h1>
|
|
||||||
|
|
||||||
{{if .Session.Error.Valid}}
|
{{if .Session.Error.Valid}}
|
||||||
<p id="error">{{html .Session.Error.String}}</p>
|
<p id="error">{{html .Session.Error.String}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
19
api/api.go
19
api/api.go
|
|
@ -1,14 +1,15 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"errors"
|
||||||
"net/http"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
"strings"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
|
|
@ -172,7 +173,7 @@ func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
|
||||||
// check cookies first
|
// check cookies first
|
||||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||||
if err != nil && err != http.ErrNoCookie {
|
if err != nil && err != http.ErrNoCookie {
|
||||||
return nil, 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 {
|
if sessionCookie != nil {
|
||||||
token = sessionCookie.Value
|
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)
|
session, err := controller.GetSession(app.DB, token)
|
||||||
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
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 {
|
if session != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,66 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ServeAllArtists(app *model.AppState) http.Handler {
|
func ServeAllArtists(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var artists = []*model.Artist{}
|
var artists = []*model.Artist{}
|
||||||
artists, err := controller.GetAllArtists(app.DB)
|
artists, err := controller.GetAllArtists(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(artists)
|
err = encoder.Encode(artists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
type (
|
type (
|
||||||
creditJSON struct {
|
creditJSON struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
|
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
|
||||||
Artwork string `json:"artwork"`
|
Artwork string `json:"artwork"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Primary bool `json:"primary"`
|
Primary bool `json:"primary"`
|
||||||
}
|
}
|
||||||
artistJSON struct {
|
artistJSON struct {
|
||||||
*model.Artist
|
*model.Artist
|
||||||
Credits map[string]creditJSON `json:"credits"`
|
Credits map[string]creditJSON `json:"credits"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
show_hidden_releases := session != nil && session.Account != nil
|
show_hidden_releases := session != nil && session.Account != nil
|
||||||
|
|
||||||
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
|
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credits = map[string]creditJSON{}
|
var credits = map[string]creditJSON{}
|
||||||
for _, credit := range dbCredits {
|
for _, credit := range dbCredits {
|
||||||
|
|
@ -74,17 +74,17 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(artistJSON{
|
err = encoder.Encode(artistJSON{
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Credits: credits,
|
Credits: credits,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtist(app *model.AppState) http.Handler {
|
func CreateArtist(app *model.AppState) http.Handler {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// only allow authorised users to view hidden releases
|
// only allow authorised users to view hidden releases
|
||||||
privileged := false
|
privileged := false
|
||||||
if !release.Visible {
|
if !release.Visible {
|
||||||
session, err := controller.GetSessionFromRequest(app, r)
|
session, err := controller.GetSessionFromRequest(app.DB, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
@ -116,15 +116,15 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err := encoder.Encode(response)
|
err := encoder.Encode(response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeCatalog(app *model.AppState) http.Handler {
|
func ServeCatalog(app *model.AppState) http.Handler {
|
||||||
|
|
|
||||||
36
api/track.go
36
api/track.go
|
|
@ -1,13 +1,13 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
|
@ -29,7 +29,7 @@ func ServeAllTracks(app *model.AppState) http.Handler {
|
||||||
dbTracks, err := controller.GetAllTracks(app.DB)
|
dbTracks, err := controller.GetAllTracks(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, track := range dbTracks {
|
for _, track := range dbTracks {
|
||||||
|
|
@ -39,23 +39,23 @@ func ServeAllTracks(app *model.AppState) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(tracks)
|
err = encoder.Encode(tracks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
|
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
|
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
releases := []string{}
|
releases := []string{}
|
||||||
|
|
@ -63,15 +63,15 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
releases = append(releases, release.ID)
|
releases = append(releases, release.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(Track{ track, releases })
|
err = encoder.Encode(Track{ track, releases })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
|
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateTrack(app *model.AppState) http.Handler {
|
func CreateTrack(app *model.AppState) http.Handler {
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,56 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
|
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
|
||||||
split := strings.Split(*data, ";base64,")
|
split := strings.Split(*data, ";base64,")
|
||||||
header := split[0]
|
header := split[0]
|
||||||
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
||||||
ext, _ := strings.CutPrefix(header, "data:image/")
|
ext, _ := strings.CutPrefix(header, "data:image/")
|
||||||
directory = filepath.Join(app.Config.DataDirectory, directory)
|
directory = filepath.Join(app.Config.DataDirectory, directory)
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case "png":
|
case "png":
|
||||||
case "jpg":
|
case "jpg":
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
default:
|
default:
|
||||||
return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
|
return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
|
||||||
}
|
}
|
||||||
filename = fmt.Sprintf("%s.%s", filename, ext)
|
filename = fmt.Sprintf("%s.%s", filename, ext)
|
||||||
|
|
||||||
// ensure directory exists
|
// ensure directory exists
|
||||||
os.MkdirAll(directory, os.ModePerm)
|
os.MkdirAll(directory, os.ModePerm)
|
||||||
|
|
||||||
imagePath := filepath.Join(directory, filename)
|
imagePath := filepath.Join(directory, filename)
|
||||||
file, err := os.Create(imagePath)
|
file, err := os.Create(imagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// TODO: generate compressed versions of image (512x512?)
|
// TODO: generate compressed versions of image (512x512?)
|
||||||
|
|
||||||
buffer := bufio.NewWriter(file)
|
buffer := bufio.NewWriter(file)
|
||||||
_, err = buffer.Write(imageData)
|
_, err = buffer.Write(imageData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := buffer.Flush(); err != nil {
|
if err := buffer.Flush(); err != nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_FILES, "\"%s\" created.", imagePath)
|
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
|
||||||
|
|
||||||
return filename, nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
bundle.sh
Executable file
9
bundle.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# simple script to pack up arimelody.me for production distribution
|
||||||
|
|
||||||
|
if [ ! -f arimelody-web ]; then
|
||||||
|
echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
|
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
|
||||||
|
|
@ -110,26 +110,3 @@ func DeleteAccount(db *sqlx.DB, accountID string) error {
|
||||||
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
|
|
||||||
failAttempts := 0
|
|
||||||
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
|
|
||||||
if err != nil { return false, err }
|
|
||||||
locked := false
|
|
||||||
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
|
|
||||||
err = LockAccount(db, accountID)
|
|
||||||
if err != nil { return false, err }
|
|
||||||
locked = true
|
|
||||||
}
|
|
||||||
return locked, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func LockAccount(db *sqlx.DB, accountID string) error {
|
|
||||||
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func UnlockAccount(db *sqlx.DB, accountID string) error {
|
|
||||||
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,48 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DATABASE
|
// DATABASE
|
||||||
|
|
||||||
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
|
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
|
||||||
var artist = model.Artist{}
|
var artist = model.Artist{}
|
||||||
|
|
||||||
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
|
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &artist, nil
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
|
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
|
||||||
var artists = []*model.Artist{}
|
var artists = []*model.Artist{}
|
||||||
|
|
||||||
err := db.Select(&artists, "SELECT * FROM artist")
|
err := db.Select(&artists, "SELECT * FROM artist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return artists, nil
|
return artists, nil
|
||||||
}
|
|
||||||
func GetArtistCount(db *sqlx.DB) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := db.Get(&count, "SELECT count(*) FROM artist")
|
|
||||||
return count, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
|
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
|
||||||
var artists = []*model.Artist{}
|
var artists = []*model.Artist{}
|
||||||
|
|
||||||
err := db.Select(&artists,
|
err := db.Select(&artists,
|
||||||
"SELECT * FROM artist "+
|
"SELECT * FROM artist "+
|
||||||
"WHERE id NOT IN "+
|
"WHERE id NOT IN "+
|
||||||
"(SELECT artist FROM musiccredit WHERE release=$1)",
|
"(SELECT artist FROM musiccredit WHERE release=$1)",
|
||||||
releaseID)
|
releaseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return artists, nil
|
return artists, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
|
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
|
||||||
|
|
@ -59,9 +54,9 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.
|
||||||
if !show_hidden { query += "AND visible=true " }
|
if !show_hidden { query += "AND visible=true " }
|
||||||
query += "ORDER BY release_date DESC"
|
query += "ORDER BY release_date DESC"
|
||||||
rows, err := db.Query(query, artistID)
|
rows, err := db.Query(query, artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type NamePrimary struct {
|
type NamePrimary struct {
|
||||||
|
|
@ -107,13 +102,13 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.
|
||||||
|
|
||||||
func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
|
func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
"INSERT INTO artist (id, name, website, avatar) "+
|
"INSERT INTO artist (id, name, website, avatar) "+
|
||||||
"VALUES ($1, $2, $3, $4)",
|
"VALUES ($1, $2, $3, $4)",
|
||||||
artist.ID,
|
artist.ID,
|
||||||
artist.Name,
|
artist.Name,
|
||||||
artist.Website,
|
artist.Website,
|
||||||
artist.Avatar,
|
artist.Avatar,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetConfig() model.Config {
|
func GetConfig() model.Config {
|
||||||
|
|
@ -18,10 +18,9 @@ func GetConfig() model.Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
config := model.Config{
|
config := model.Config{
|
||||||
BaseUrl: "https://arimelody.space",
|
BaseUrl: "https://arimelody.me",
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
TrustedProxies: []string{ "127.0.0.1" },
|
|
||||||
DB: model.DBConfig{
|
DB: model.DBConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 5432,
|
Port: 5432,
|
||||||
|
|
@ -77,9 +76,5 @@ func handleConfigOverrides(config *model.Config) error {
|
||||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
|
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
|
||||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
|
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
|
||||||
|
|
||||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
|
|
||||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
|
|
||||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import "math/rand"
|
||||||
func GenerateAlnumString(length int) []byte {
|
func GenerateAlnumString(length int) []byte {
|
||||||
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
res := []byte{}
|
res := []byte{}
|
||||||
for range length {
|
for i := 0; i < length; i++ {
|
||||||
res = append(res, CHARS[rand.Intn(len(CHARS))])
|
res = append(res, CHARS[rand.Intn(len(CHARS))])
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"net/http"
|
||||||
"net/http"
|
"slices"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Returns the request's original IP address, resolving the `x-forwarded-for`
|
// Returns the request's original IP address, resolving the `x-forwarded-for`
|
||||||
// header if the request originates from a trusted proxy.
|
// header if the request originates from a trusted proxy.
|
||||||
func ResolveIP(app *model.AppState, r *http.Request) string {
|
func ResolveIP(r *http.Request) string {
|
||||||
addr := strings.Split(r.RemoteAddr, ":")[0]
|
trustedProxies := []string{ "10.4.20.69" }
|
||||||
if slices.Contains(app.Config.TrustedProxies, addr) {
|
if slices.Contains(trustedProxies, r.RemoteAddr) {
|
||||||
forwardedFor := r.Header.Get("x-forwarded-for")
|
forwardedFor := r.Header.Get("x-forwarded-for")
|
||||||
if len(forwardedFor) > 0 {
|
if len(forwardedFor) > 0 {
|
||||||
// discard extra IPs; cloudflare tends to append their nodes
|
|
||||||
forwardedFor = strings.Split(forwardedFor, ", ")[0]
|
|
||||||
return forwardedFor
|
return forwardedFor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return addr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -9,7 +8,7 @@ import (
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DB_VERSION int = 4
|
const DB_VERSION int = 3
|
||||||
|
|
||||||
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
||||||
|
|
@ -46,23 +45,16 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
ApplyMigration(db, "002-audit-logs")
|
ApplyMigration(db, "002-audit-logs")
|
||||||
oldDBVersion = 3
|
oldDBVersion = 3
|
||||||
|
|
||||||
case 3:
|
|
||||||
ApplyMigration(db, "003-fail-lock")
|
|
||||||
oldDBVersion = 4
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Database schema up to date.\n")
|
fmt.Printf("Database schema up to date.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed "schema-migration"
|
|
||||||
var schemaFS embed.FS
|
|
||||||
|
|
||||||
func ApplyMigration(db *sqlx.DB, scriptFile string) {
|
func ApplyMigration(db *sqlx.DB, scriptFile string) {
|
||||||
fmt.Printf("Applying schema migration %s...\n", scriptFile)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
|
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
138
controller/qr.go
138
controller/qr.go
|
|
@ -1,9 +1,13 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"bytes"
|
||||||
// "image"
|
"encoding/base64"
|
||||||
// "image/color"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
|
||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
)
|
)
|
||||||
|
|
@ -18,35 +22,99 @@ func GenerateQRCode(data string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// vvv DEPRECATED vvv
|
// vvv DEPRECATED vvv
|
||||||
// const margin = 4
|
|
||||||
//
|
const margin = 4
|
||||||
// type QRCodeECCLevel int64
|
|
||||||
// const (
|
type QRCodeECCLevel int64
|
||||||
// LOW QRCodeECCLevel = iota
|
const (
|
||||||
// MEDIUM
|
LOW QRCodeECCLevel = iota
|
||||||
// QUARTILE
|
MEDIUM
|
||||||
// HIGH
|
QUARTILE
|
||||||
// )
|
HIGH
|
||||||
//
|
)
|
||||||
// func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
|
|
||||||
// for yi := range 7 {
|
func noDepsGenerateQRCode() (string, error) {
|
||||||
// for xi := range 7 {
|
version := 1
|
||||||
// if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
|
|
||||||
// img.Set(x + xi, y + yi, color.Black)
|
size := 0
|
||||||
// } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
|
size = 21 + version * 4
|
||||||
// img.Set(x + xi, y + yi, color.Black)
|
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))
|
||||||
//
|
|
||||||
// func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
|
// fill white
|
||||||
// for yi := range 5 {
|
for y := range size + margin * 2 {
|
||||||
// for xi := range 5 {
|
for x := range size + margin * 2 {
|
||||||
// if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
|
img.Set(x, y, color.White)
|
||||||
// img.Set(x + xi, y + yi, color.Black)
|
}
|
||||||
// }
|
}
|
||||||
// }
|
|
||||||
// }
|
// draw alignment squares
|
||||||
// img.Set(x + 2, y + 2, color.Black)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
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) {
|
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
|
// get credits
|
||||||
credits, err := GetReleaseCredits(db, id)
|
credits, err := GetReleaseCredits(db, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Credits: %s", err)
|
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
|
||||||
}
|
}
|
||||||
for _, credit := range credits {
|
for _, credit := range credits {
|
||||||
release.Credits = append(release.Credits, credit)
|
release.Credits = append(release.Credits, credit)
|
||||||
|
|
@ -29,7 +30,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
||||||
// get tracks
|
// get tracks
|
||||||
tracks, err := GetReleaseTracks(db, id)
|
tracks, err := GetReleaseTracks(db, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Tracks: %s", err)
|
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
|
||||||
}
|
}
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
release.Tracks = append(release.Tracks, track)
|
release.Tracks = append(release.Tracks, track)
|
||||||
|
|
@ -38,7 +39,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
||||||
// get links
|
// get links
|
||||||
links, err := GetReleaseLinks(db, id)
|
links, err := GetReleaseLinks(db, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Links: %s", err)
|
return nil, errors.New(fmt.Sprintf("Links: %s", err))
|
||||||
}
|
}
|
||||||
for _, link := range links {
|
for _, link := range links {
|
||||||
release.Links = append(release.Links, link)
|
release.Links = append(release.Links, link)
|
||||||
|
|
@ -70,7 +71,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
||||||
// get credits
|
// get credits
|
||||||
credits, err := GetReleaseCredits(db, release.ID)
|
credits, err := GetReleaseCredits(db, release.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Credits: %s", err)
|
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
|
||||||
}
|
}
|
||||||
for _, credit := range credits {
|
for _, credit := range credits {
|
||||||
release.Credits = append(release.Credits, credit)
|
release.Credits = append(release.Credits, credit)
|
||||||
|
|
@ -80,7 +81,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
||||||
// get tracks
|
// get tracks
|
||||||
tracks, err := GetReleaseTracks(db, release.ID)
|
tracks, err := GetReleaseTracks(db, release.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Tracks: %s", err)
|
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
|
||||||
}
|
}
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
release.Tracks = append(release.Tracks, track)
|
release.Tracks = append(release.Tracks, track)
|
||||||
|
|
@ -89,7 +90,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
||||||
// get links
|
// get links
|
||||||
links, err := GetReleaseLinks(db, release.ID)
|
links, err := GetReleaseLinks(db, release.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Links: %s", err)
|
return nil, errors.New(fmt.Sprintf("Links: %s", err))
|
||||||
}
|
}
|
||||||
for _, link := range links {
|
for _, link := range links {
|
||||||
release.Links = append(release.Links, link)
|
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
|
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 {
|
func CreateRelease(db *sqlx.DB, release *model.Release) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,50 +1,38 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"errors"
|
||||||
"net/http"
|
"fmt"
|
||||||
"strings"
|
"net/http"
|
||||||
"time"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TOKEN_LEN = 64
|
const TOKEN_LEN = 64
|
||||||
|
|
||||||
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
|
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) {
|
||||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||||
if err != nil && err != http.ErrNoCookie {
|
if err != nil && err != http.ErrNoCookie {
|
||||||
return nil, 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
|
var session *model.Session
|
||||||
|
|
||||||
if sessionCookie != nil {
|
if sessionCookie != nil {
|
||||||
// fetch existing session
|
// fetch existing session
|
||||||
session, err = GetSession(app.DB, sessionCookie.Value)
|
session, err = GetSession(db, sessionCookie.Value)
|
||||||
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||||
return nil, 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 != nil {
|
||||||
if session.UserAgent != r.UserAgent() {
|
// TODO: consider running security checks here (i.e. user agent mismatches)
|
||||||
msg := "Session user agent mismatch. A cookie may have been hijacked!"
|
|
||||||
if session.Account != nil {
|
|
||||||
account, _ := GetAccountByID(app.DB, session.Account.ID)
|
|
||||||
msg += " (Account \"" + account.Username + "\")"
|
|
||||||
}
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, msg)
|
|
||||||
err = DeleteSession(app.DB, session.Token)
|
|
||||||
if err != nil {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +175,3 @@ func DeleteSession(db *sqlx.DB, token string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteExpiredSessions(db *sqlx.DB) error {
|
|
||||||
_, err := db.Exec("DELETE FROM session WHERE expires_at<current_timestamp")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TOTP_SECRET_LENGTH = 32
|
const TOTP_SECRET_LENGTH = 32
|
||||||
|
|
@ -58,12 +58,12 @@ func GenerateTOTPURI(username string, secret string) string {
|
||||||
url := url.URL{
|
url := url.URL{
|
||||||
Scheme: "otpauth",
|
Scheme: "otpauth",
|
||||||
Host: "totp",
|
Host: "totp",
|
||||||
Path: url.QueryEscape("arimelody.space") + ":" + url.QueryEscape(username),
|
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
|
||||||
}
|
}
|
||||||
|
|
||||||
query := url.Query()
|
query := url.Query()
|
||||||
query.Set("secret", secret)
|
query.Set("secret", secret)
|
||||||
query.Set("issuer", "arimelody.space")
|
query.Set("issuer", "arimelody.me")
|
||||||
// query.Set("algorithm", "SHA1")
|
// query.Set("algorithm", "SHA1")
|
||||||
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
|
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
|
||||||
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
|
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DATABASE
|
// DATABASE
|
||||||
|
|
@ -13,58 +13,53 @@ func GetTrack(db *sqlx.DB, id string) (*model.Track, error) {
|
||||||
|
|
||||||
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
|
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
|
||||||
err := stmt.Get(&track, id)
|
err := stmt.Get(&track, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
|
func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks, "SELECT * FROM musictrack")
|
err := db.Select(&tracks, "SELECT * FROM musictrack")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
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) {
|
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
|
err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
|
func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks,
|
err := db.Select(&tracks,
|
||||||
"SELECT * FROM musictrack "+
|
"SELECT * FROM musictrack "+
|
||||||
"WHERE id NOT IN "+
|
"WHERE id NOT IN "+
|
||||||
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
|
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
|
||||||
releaseID)
|
releaseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
|
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
|
||||||
var releases = []*model.Release{}
|
var releases = []*model.Release{}
|
||||||
|
|
||||||
err := db.Select(&releases,
|
err := db.Select(&releases,
|
||||||
"SELECT id,title,type,release_date,artwork,buylink "+
|
"SELECT id,title,type,release_date,artwork,buylink "+
|
||||||
"FROM musicrelease "+
|
"FROM musicrelease "+
|
||||||
"JOIN musicreleasetrack ON release=id "+
|
"JOIN musicreleasetrack ON release=id "+
|
||||||
|
|
@ -72,9 +67,9 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release,
|
||||||
"ORDER BY release_date",
|
"ORDER BY release_date",
|
||||||
trackID,
|
trackID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type NamePrimary struct {
|
type NamePrimary struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -119,14 +114,14 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release,
|
||||||
func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks,
|
err := db.Select(&tracks,
|
||||||
"SELECT id, title, description, lyrics, preview_url FROM musictrack "+
|
"SELECT id, title, description, lyrics, preview_url FROM musictrack "+
|
||||||
"WHERE id NOT IN "+
|
"WHERE id NOT IN "+
|
||||||
"(SELECT track FROM musicreleasetrack)",
|
"(SELECT track FROM musicreleasetrack)",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
201
cursor/cursor.go
201
cursor/cursor.go
|
|
@ -1,201 +0,0 @@
|
||||||
package cursor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/model"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CursorClient struct {
|
|
||||||
ID int32
|
|
||||||
Conn *websocket.Conn
|
|
||||||
Route string
|
|
||||||
X float32
|
|
||||||
Y float32
|
|
||||||
Click bool
|
|
||||||
Disconnected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type CursorMessage struct {
|
|
||||||
Data []byte
|
|
||||||
Route string
|
|
||||||
Exclude []*CursorClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *CursorClient) Send(data []byte) {
|
|
||||||
err := client.Conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
if err != nil {
|
|
||||||
client.Disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *CursorClient) Disconnect() {
|
|
||||||
client.Disconnected = true
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("leave:%d", client.ID)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var clients = make(map[int32]*CursorClient)
|
|
||||||
var broadcast = make(chan CursorMessage)
|
|
||||||
var mutex = &sync.Mutex{}
|
|
||||||
|
|
||||||
func StartCursor(app *model.AppState) {
|
|
||||||
var includes = func (clients []*CursorClient, client *CursorClient) bool {
|
|
||||||
for _, c := range clients {
|
|
||||||
if c.ID == client.ID { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
log("Cursor message handler ready!")
|
|
||||||
|
|
||||||
for {
|
|
||||||
message := <-broadcast
|
|
||||||
mutex.Lock()
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.Route != message.Route { continue }
|
|
||||||
if includes(message.Exclude, client) { continue }
|
|
||||||
client.Send(message.Data)
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleClient(client *CursorClient) {
|
|
||||||
msgType, message, err := client.Conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
client.Disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if msgType != websocket.TextMessage { return }
|
|
||||||
|
|
||||||
args := strings.Split(string(message), ":")
|
|
||||||
if len(args) == 0 { return }
|
|
||||||
switch args[0] {
|
|
||||||
case "loc":
|
|
||||||
if len(args) < 2 { return }
|
|
||||||
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("join:%d", client.ID)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "char":
|
|
||||||
if len(args) < 2 { return }
|
|
||||||
// haha, turns out using ':' as a separator means you can't type ':'s
|
|
||||||
// i should really be writing byte packets, not this nonsense
|
|
||||||
msg := byte(':')
|
|
||||||
if len(args[1]) > 0 {
|
|
||||||
msg = args[1][0]
|
|
||||||
}
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("char:%d:%c", client.ID, msg)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "nochar":
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("nochar:%d", client.ID)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "click":
|
|
||||||
if len(args) < 2 { return }
|
|
||||||
click := 0
|
|
||||||
if args[1][0] == '1' {
|
|
||||||
click = 1
|
|
||||||
}
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("click:%d:%d", client.ID, click)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "pos":
|
|
||||||
if len(args) < 3 { return }
|
|
||||||
x, err := strconv.ParseFloat(args[1], 32)
|
|
||||||
y, err := strconv.ParseFloat(args[2], 32)
|
|
||||||
if err != nil { return }
|
|
||||||
client.X = float32(x)
|
|
||||||
client.Y = float32(y)
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.HandlerFunc {
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func (r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
return origin == app.Config.BaseUrl
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
|
||||||
if err != nil {
|
|
||||||
log("Failed to upgrade to WebSocket connection: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := CursorClient{
|
|
||||||
ID: rand.Int31(),
|
|
||||||
Conn: conn,
|
|
||||||
X: 0.0,
|
|
||||||
Y: 0.0,
|
|
||||||
Disconnected: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.Conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("id:%d", client.ID)))
|
|
||||||
if err != nil {
|
|
||||||
client.Conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mutex.Lock()
|
|
||||||
clients[client.ID] = &client
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
// log("Client connected: %s (%s)", fmt.Sprintf("0x%08x", client.ID), client.Conn.RemoteAddr().String())
|
|
||||||
|
|
||||||
for {
|
|
||||||
if client.Disconnected {
|
|
||||||
mutex.Lock()
|
|
||||||
delete(clients, client.ID)
|
|
||||||
client.Conn.Close()
|
|
||||||
mutex.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleClient(&client)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func log(format string, args ...any) {
|
|
||||||
logString := fmt.Sprintf(format, args...)
|
|
||||||
fmt.Printf("[%s] [CURSOR] %s\n", time.Now().Format(time.UnixDate), logString)
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const API_ENDPOINT = "https://discord.com/api/v10"
|
const API_ENDPOINT = "https://discord.com/api/v10"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: docker.arimelody.space/arimelody-web:latest
|
image: docker.arimelody.me/arimelody.me:latest
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -10,7 +10,6 @@ require (
|
||||||
require golang.org/x/crypto v0.27.0 // indirect
|
require golang.org/x/crypto v0.27.0 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
|
@ -30,7 +30,6 @@ const (
|
||||||
TYPE_ARTWORK string = "artwork"
|
TYPE_ARTWORK string = "artwork"
|
||||||
TYPE_FILES string = "files"
|
TYPE_FILES string = "files"
|
||||||
TYPE_MISC string = "misc"
|
TYPE_MISC string = "misc"
|
||||||
TYPE_CURSOR string = "cursor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LogLevel int
|
type LogLevel int
|
||||||
|
|
|
||||||
185
main.go
185
main.go
|
|
@ -1,14 +1,11 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"embed"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
stdLog "log"
|
stdLog "log"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -20,9 +17,9 @@ import (
|
||||||
"arimelody-web/api"
|
"arimelody-web/api"
|
||||||
"arimelody-web/colour"
|
"arimelody-web/colour"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/cursor"
|
|
||||||
"arimelody-web/log"
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
"arimelody-web/templates"
|
||||||
|
"arimelody-web/log"
|
||||||
"arimelody-web/view"
|
"arimelody-web/view"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
@ -34,18 +31,12 @@ import (
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
|
|
||||||
const DEFAULT_PORT int64 = 8080
|
const DEFAULT_PORT int64 = 8080
|
||||||
const HRT_DATE int64 = 1756478697
|
|
||||||
|
|
||||||
//go:embed "public"
|
|
||||||
var publicFS embed.FS
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Printf("made with <3 by ari melody\n\n")
|
fmt.Printf("made with <3 by ari melody\n\n")
|
||||||
|
|
||||||
app := model.AppState{
|
app := model.AppState{
|
||||||
Config: controller.GetConfig(),
|
Config: controller.GetConfig(),
|
||||||
Twitch: nil,
|
|
||||||
PublicFS: publicFS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialise database connection
|
// initialise database connection
|
||||||
|
|
@ -282,13 +273,11 @@ func main() {
|
||||||
"User: %s\n" +
|
"User: %s\n" +
|
||||||
"\tID: %s\n" +
|
"\tID: %s\n" +
|
||||||
"\tEmail: %s\n" +
|
"\tEmail: %s\n" +
|
||||||
"\tCreated: %s\n" +
|
"\tCreated: %s\n",
|
||||||
"\tLocked: %t\n",
|
|
||||||
account.Username,
|
account.Username,
|
||||||
account.ID,
|
account.ID,
|
||||||
email,
|
email,
|
||||||
account.CreatedAt,
|
account.CreatedAt,
|
||||||
account.Locked,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -363,64 +352,6 @@ func main() {
|
||||||
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "lockAccount":
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
username := os.Args[2]
|
|
||||||
fmt.Printf("Unlocking account \"%s\"...\n", username)
|
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if account == nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = controller.LockAccount(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
|
|
||||||
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
|
|
||||||
return
|
|
||||||
|
|
||||||
case "unlockAccount":
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
username := os.Args[2]
|
|
||||||
fmt.Printf("Unlocking account \"%s\"...\n", username)
|
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if account == nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = controller.UnlockAccount(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
|
|
||||||
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
|
|
||||||
return
|
|
||||||
|
|
||||||
case "logs":
|
case "logs":
|
||||||
// TODO: add log search parameters
|
// TODO: add log search parameters
|
||||||
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
|
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
|
||||||
|
|
@ -455,10 +386,7 @@ func main() {
|
||||||
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
||||||
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
||||||
"listAccounts:\n\tLists all active accounts.\n",
|
"listAccounts:\n\tLists all active accounts.\n",
|
||||||
"deleteAccount <username>:\n\tDeletes the account under `username`.\n",
|
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
|
||||||
"lockAccount <username>:\n\tLocks the account under `username`.\n",
|
|
||||||
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
|
|
||||||
"logs:\n\tShows system logs.\n",
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -466,13 +394,6 @@ func main() {
|
||||||
// handle DB migrations
|
// handle DB migrations
|
||||||
controller.CheckDBVersionAndMigrate(app.DB)
|
controller.CheckDBVersionAndMigrate(app.DB)
|
||||||
|
|
||||||
if app.Config.Twitch != nil {
|
|
||||||
err = controller.TwitchSetup(&app)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial invite code
|
// initial invite code
|
||||||
accountsCount := 0
|
accountsCount := 0
|
||||||
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||||
|
|
@ -493,13 +414,6 @@ func main() {
|
||||||
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
|
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete expired sessions
|
|
||||||
err = controller.DeleteExpiredSessions(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete expired invites
|
// delete expired invites
|
||||||
err = controller.DeleteExpiredInvites(app.DB)
|
err = controller.DeleteExpiredInvites(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -514,14 +428,12 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
go cursor.StartCursor(&app)
|
|
||||||
|
|
||||||
// start the web server!
|
// start the web server!
|
||||||
mux := createServeMux(&app)
|
mux := createServeMux(&app)
|
||||||
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
|
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
|
||||||
stdLog.Fatal(
|
stdLog.Fatal(
|
||||||
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
|
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
|
||||||
CheckRequest(&app, HTTPLog(DefaultHeaders(mux))),
|
HTTPLog(DefaultHeaders(mux)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,13 +443,48 @@ func createServeMux(app *model.AppState) *http.ServeMux {
|
||||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
||||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.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("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
mux.Handle("/", view.IndexHandler(app))
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
|
err := templates.IndexTemplate.Execute(w, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
staticHandler("public").ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func staticHandler(directory string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
|
||||||
|
|
||||||
|
// does the file exist?
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is thjs a directory? (forbidden)
|
||||||
|
if info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var PoweredByStrings = []string{
|
var PoweredByStrings = []string{
|
||||||
"nerd rage",
|
"nerd rage",
|
||||||
"estrogen",
|
"estrogen",
|
||||||
|
|
@ -568,39 +515,9 @@ var PoweredByStrings = []string{
|
||||||
"30 billion dollars in VC funding",
|
"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 {
|
func DefaultHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Add("Server", "ari melody webbed site")
|
w.Header().Add("Server", "arimelody.me")
|
||||||
w.Header().Add("Do-Not-Stab", "1")
|
w.Header().Add("Do-Not-Stab", "1")
|
||||||
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||||
w.Header().Add("X-Hacker", "spare me please")
|
w.Header().Add("X-Hacker", "spare me please")
|
||||||
|
|
@ -610,10 +527,6 @@ func DefaultHeaders(next http.Handler) http.Handler {
|
||||||
"X-Powered-By",
|
"X-Powered-By",
|
||||||
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -623,14 +536,6 @@ type LoggingResponseWriter struct {
|
||||||
Status int
|
Status int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lrw *LoggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
||||||
hijack, ok := lrw.ResponseWriter.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil, errors.New("Server does not support hijacking\n")
|
|
||||||
}
|
|
||||||
return hijack.Hijack()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||||
lrw.Status = status
|
lrw.Status = status
|
||||||
lrw.ResponseWriter.WriteHeader(status)
|
lrw.ResponseWriter.WriteHeader(status)
|
||||||
|
|
@ -666,6 +571,6 @@ func HTTPLog(next http.Handler) http.Handler {
|
||||||
lrw.Status,
|
lrw.Status,
|
||||||
colour.Reset,
|
colour.Reset,
|
||||||
elapsed,
|
elapsed,
|
||||||
r.Header.Get("User-Agent"))
|
r.Header["User-Agent"][0])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,20 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const COOKIE_TOKEN string = "AM_SESSION"
|
const COOKIE_TOKEN string = "AM_SESSION"
|
||||||
const MAX_LOGIN_FAIL_ATTEMPTS int = 3
|
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Account struct {
|
Account struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
Username string `json:"username" db:"username"`
|
Username string `json:"username" db:"username"`
|
||||||
Password string `json:"password" db:"password"`
|
Password string `json:"password" db:"password"`
|
||||||
Email sql.NullString `json:"email" db:"email"`
|
Email sql.NullString `json:"email" db:"email"`
|
||||||
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
|
|
||||||
Locked bool `json:"locked" db:"locked"`
|
|
||||||
|
|
||||||
Privileges []AccountPrivilege `json:"privileges"`
|
Privileges []AccountPrivilege `json:"privileges"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
|
@ -23,28 +21,18 @@ type (
|
||||||
Secret string `toml:"secret"`
|
Secret string `toml:"secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
TwitchConfig struct {
|
|
||||||
Broadcaster string `toml:"broadcaster"`
|
|
||||||
ClientID string `toml:"client_id"`
|
|
||||||
Secret string `toml:"secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
Config struct {
|
Config struct {
|
||||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||||
Host string `toml:"host"`
|
Host string `toml:"host"`
|
||||||
Port int64 `toml:"port"`
|
Port int64 `toml:"port"`
|
||||||
DataDirectory string `toml:"data_dir"`
|
DataDirectory string `toml:"data_dir"`
|
||||||
TrustedProxies []string `toml:"trusted_proxies"`
|
|
||||||
DB DBConfig `toml:"db"`
|
DB DBConfig `toml:"db"`
|
||||||
Discord *DiscordConfig `toml:"discord"`
|
Discord DiscordConfig `toml:"discord"`
|
||||||
Twitch *TwitchConfig `toml:"twitch"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AppState struct {
|
AppState struct {
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
Config Config
|
Config Config
|
||||||
Log log.Logger
|
Log log.Logger
|
||||||
Twitch *TwitchState
|
|
||||||
PublicFS embed.FS
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Artist struct {
|
Artist struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (artist Artist) GetAvatar() string {
|
func (artist Artist) GetWebsite() string {
|
||||||
if artist.Avatar == "" {
|
return artist.Website
|
||||||
return "/img/default-avatar.png"
|
}
|
||||||
}
|
|
||||||
return artist.Avatar
|
func (artist Artist) GetAvatar() string {
|
||||||
|
if artist.Avatar == "" {
|
||||||
|
return "/img/default-avatar.png"
|
||||||
|
}
|
||||||
|
return artist.Avatar
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (link Link) NormaliseName() string {
|
func (link Link) NormaliseName() string {
|
||||||
rgx := regexp.MustCompile(`[^a-z0-9\-]`)
|
rgx := regexp.MustCompile(`[^a-z0-9]`)
|
||||||
return rgx.ReplaceAllString(strings.ToLower(link.Name), "")
|
return strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
|
@ -39,7 +39,7 @@ const (
|
||||||
// GETTERS
|
// GETTERS
|
||||||
|
|
||||||
func (release Release) GetDescriptionHTML() template.HTML {
|
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 {
|
func (release Release) TextReleaseDate() string {
|
||||||
|
|
@ -50,6 +50,10 @@ func (release Release) PrintReleaseDate() string {
|
||||||
return release.ReleaseDate.Format("02 January 2006")
|
return release.ReleaseDate.Format("02 January 2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (release Release) GetReleaseYear() int {
|
||||||
|
return release.ReleaseDate.Year()
|
||||||
|
}
|
||||||
|
|
||||||
func (release Release) GetArtwork() string {
|
func (release Release) GetArtwork() string {
|
||||||
if release.Artwork == "" {
|
if release.Artwork == "" {
|
||||||
return "/img/default-cover-art.png"
|
return "/img/default-cover-art.png"
|
||||||
|
|
@ -73,23 +77,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string {
|
||||||
names = append(names, credit.Artist.Name)
|
names = append(names, credit.Artist.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
|
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
|
||||||
names := release.GetUniqueArtistNames(only_primary)
|
names := release.GetUniqueArtistNames(only_primary)
|
||||||
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
return "Unknown Artist"
|
return "Unknown Artist"
|
||||||
} else if len(names) == 1 {
|
} else if len(names) == 1 {
|
||||||
return names[0]
|
return names[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if ampersand {
|
if ampersand {
|
||||||
res := strings.Join(names[:len(names)-1], ", ")
|
res := strings.Join(names[:len(names)-1], ", ")
|
||||||
res += " & " + names[len(names)-1]
|
res += " & " + names[len(names)-1]
|
||||||
return res
|
return res
|
||||||
} else {
|
} else {
|
||||||
return strings.Join(names[:], ", ")
|
return strings.Join(names[:], ", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TOTP struct {
|
type TOTP struct {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Track struct {
|
Track struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Lyrics string `json:"lyrics" db:"lyrics"`
|
Lyrics string `json:"lyrics" db:"lyrics"`
|
||||||
PreviewURL string `json:"previewURL" db:"preview_url"`
|
PreviewURL string `json:"previewURL" db:"preview_url"`
|
||||||
|
|
||||||
Number int `json:"-"`
|
Number int
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (track Track) GetDescriptionHTML() template.HTML {
|
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 {
|
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
|
// this function is stupid and i hate that i need it
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue