Compare commits

..

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

155 changed files with 3165 additions and 7136 deletions

View file

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

View file

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

View file

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

2
.gitignore vendored
View file

@ -7,5 +7,3 @@ uploads/
docker-compose*.yml
!docker-compose.example.yml
config*.toml
arimelody-web
arimelody-web.tar.gz

View file

@ -1,22 +0,0 @@
MIT License
Copyright (c) 2025-present ari melody
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -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

View file

@ -1,12 +1,17 @@
# ari melody website
# arimelody.me
home to your local SPACEGIRL! 💫
---
a slightly-overcomplicated webserver built to show off everything i've worked
on, and then some! this server comes complete with twitch live status tracking,
a portfolio database, and a full-fledged admin CMS panel to manage it all!
built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static)
branch, this powerful, server-side rendered version comes complete with live
updates, powered by a new database and handy admin panel!
the admin panel currently facilitates live updating of my music discography,
though i plan to expand it towards art portfolio and blog posts in the future.
if all goes well, i'd like to later separate these components into their own
library for others to use in their own sites. exciting stuff!
## build
@ -37,11 +42,7 @@ need to be up for this, making this ideal for some offline maintenance.
- `listTOTP <username>`: Lists an account's TOTP methods.
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
- `cleanTOTP`: Cleans up unconfirmed (dangling) TOTP methods.
- `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts.
- `deleteAccount <username>`: Deletes an account with a given `username`.
- `lockAccount <username>`: Locks the account under `username`.
- `unlockAccount <username>`: Unlocks the account under `username`.
- `logs`: Shows system logs.

View file

@ -1,72 +1,43 @@
package admin
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/global"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/account/totp-setup", totpSetupHandler(app))
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
mux.Handle("/account/password", changePasswordHandler(app))
mux.Handle("/account/delete", deleteAccountHandler(app))
return mux
type TemplateData struct {
Account *model.Account
Message string
Token string
}
func accountIndexHandler(app *model.AppState) http.Handler {
func AccountHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
account := r.Context().Value("account").(*model.Account)
dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID)
totps, err := controller.GetTOTPsForAccount(db, account.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
type (
TOTP struct {
model.TOTP
CreatedAtString string
}
accountResponse struct {
adminPageData
TOTPs []TOTP
}
)
totps := []TOTP{}
for _, totp := range dbTOTPs {
totps = append(totps, TOTP{
TOTP: totp,
CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"),
})
type AccountResponse struct {
Account *model.Account
TOTPs []model.TOTP
}
sessionMessage := session.Message
sessionError := session.Error
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
session.Message = sessionMessage
session.Error = sessionError
err = templates.AccountTemplate.Execute(w, accountResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = pages["account"].Execute(w, AccountResponse{
Account: account,
TOTPs: totps,
})
if err != nil {
@ -76,116 +47,189 @@ func accountIndexHandler(app *model.AppState) http.Handler {
})
}
func changePasswordHandler(app *model.AppState) http.Handler {
func LoginHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
account, err := controller.GetAccountByRequest(db, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account != nil {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
err = pages["login"].Execute(w, TemplateData{})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
return
}
type LoginResponse struct {
Account *model.Account
Token string
Message string
}
render := func(data LoginResponse) {
err := pages["login"].Execute(w, data)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
r.ParseForm()
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
newPassword := r.Form.Get("new-password")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
session.Account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, session.Account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}
func deleteAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
http.NotFound(w, r);
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
render(LoginResponse{ Message: "Malformed request." })
return
}
if !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
}
credentials := LoginRequest{
Username: r.Form.Get("username"),
Password: r.Form.Get("password"),
TOTP: r.Form.Get("totp"),
}
session := r.Context().Value("session").(*model.Session)
// check password
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))
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
err = controller.DeleteAccount(app.DB, session.Account.ID)
account, err := controller.GetAccount(db, credentials.Username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
render(LoginResponse{ Message: "Invalid username or password" })
return
}
if account == nil {
render(LoginResponse{ Message: "Invalid username or password" })
return
}
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
render(LoginResponse{ Message: "Invalid username or password" })
return
}
controller.SetSessionAccount(app.DB, session, nil)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Account deleted successfully.")
totps, err := controller.GetTOTPsForAccount(db, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
render(LoginResponse{ Message: "Something went wrong. Please try again." })
return
}
if len(totps) > 0 {
success := false
for _, totp := range totps {
check := controller.GenerateTOTP(totp.Secret, 0)
if check == credentials.TOTP {
success = true
break
}
}
if !success {
render(LoginResponse{ Message: "Invalid TOTP" })
return
}
} else {
// TODO: user should be prompted to add 2FA method
}
// login success!
token, err := controller.CreateToken(db, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
render(LoginResponse{ Message: "Something went wrong. Please try again." })
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
render(LoginResponse{ Account: account, Token: token.Token })
})
}
func LogoutHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
tokenStr := controller.GetTokenFromRequest(db, r)
if len(tokenStr) > 0 {
err := controller.DeleteToken(db, tokenStr)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = ""
cookie.Expires = time.Now()
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/admin/login", http.StatusFound)
})
}
type totpConfirmData struct {
adminPageData
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
}
func totpSetupHandler(app *model.AppState) http.Handler {
func createAccountHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
session := r.Context().Value("session").(*model.Session)
checkAccount, err := controller.GetAccountByRequest(db, r)
if err != nil {
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if checkAccount != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
type CreateAccountResponse struct {
Account *model.Account
Message string
}
render := func(data CreateAccountResponse) {
err := pages["create-account"].Execute(w, data)
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render(CreateAccountResponse{})
return
}
@ -194,175 +238,108 @@ func totpSetupHandler(app *model.AppState) http.Handler {
return
}
err := r.ParseForm()
err = r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
session := r.Context().Value("session").(*model.Session)
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP {
AccountID: session.Account.ID,
Name: name,
Secret: string(secret),
}
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
render(CreateAccountResponse{
Message: "Malformed data.",
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
qrBase64Image, err := controller.GenerateQRCode(
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
// make sure code exists in DB
invite, err := controller.GetInvite(db, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(db, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
render(CreateAccountResponse{
Message: "Invalid invite code.",
})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: credentials.Email,
AvatarURL: "/img/default-avatar.png",
}
err = controller.CreateAccount(db, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
render(CreateAccountResponse{
Message: "An account with that username already exists.",
})
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
err = controller.DeleteInvite(db, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
// registration success!
token, err := controller.CreateToken(db, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
// gracefully redirect user to login page
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, TemplateData{
Account: &account,
Token: token.Token,
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func totpConfirmHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totp == nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
qrBase64Image, err := controller.GenerateQRCode(
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
code := r.FormValue("totp")
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}
func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totp == nil {
http.NotFound(w, r)
return
}
err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name)
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}

View file

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

View file

@ -1,13 +1,13 @@
<dialog id="addcredit">
<header>
<h2>Add Artist Credit</h2>
<h2>Add artist credit</h2>
</header>
<ul>
{{range $Artist := .Artists}}
<li class="new-artist"
data-id="{{$Artist.ID}}"
hx-get="/admin/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-target="#editcredits ul"
hx-swap="beforeend"
>

View file

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

View file

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

View file

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

View file

@ -3,20 +3,20 @@
<h2>Editing: Tracks</h2>
<a id="add-track"
class="button new"
href="/admin/releases/{{.Release.ID}}/addtrack"
hx-get="/admin/releases/{{.Release.ID}}/addtrack"
href="/admin/release/{{.ID}}/addtrack"
hx-get="/admin/release/{{.ID}}/addtrack"
hx-target="body"
hx-swap="beforeend"
>Add</a>
</header>
<form action="/api/v1/music/{{.Release.ID}}/tracks">
<form action="/api/v1/music/{{.ID}}/tracks">
<ul>
{{range $i, $track := .Release.Tracks}}
{{range $i, $track := .Tracks}}
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
<div>
<p class="track-name">
<span class="track-number">{{.Add $i 1}}</span>
<span class="track-number">{{$track.Add $i 1}}</span>
{{$track.Title}}
</p>
<a class="delete">Delete</a>

View file

@ -2,574 +2,124 @@ package admin
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"strings"
"time"
"path/filepath"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/view"
"golang.org/x/crypto/bcrypt"
"github.com/jmoiron/sqlx"
)
type adminPageData struct {
Path string
Session *model.Session
}
func Handler(app *model.AppState) http.Handler {
func Handler(db *sqlx.DB) http.Handler {
mux := http.NewServeMux()
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
}))
mux.Handle("/login", loginHandler(app))
mux.Handle("/totp", loginTOTPHandler(app))
mux.Handle("/logout", requireAccount(logoutHandler(app)))
mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(accountHandler(app)))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/releases", requireAccount(serveReleases(app)))
mux.Handle("/releases/", requireAccount(serveReleases(app)))
mux.Handle("/artists", requireAccount(serveArtists(app)))
mux.Handle("/artists/", requireAccount(serveArtists(app)))
mux.Handle("/tracks", requireAccount(serveTracks(app)))
mux.Handle("/tracks/", requireAccount(serveTracks(app)))
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/static/admin.css" {
http.ServeFile(w, r, "./admin/static/admin.css")
return
}
if r.URL.Path == "/static/admin.js" {
http.ServeFile(w, r, "./admin/static/admin.js")
return
}
requireAccount(
http.StripPrefix("/static",
view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
}))
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
// response wrapper to make sure a session cookie exists
return enforceSession(app, mux)
}
func AdminIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/login", LoginHandler(db))
mux.Handle("/register", createAccountHandler(db))
mux.Handle("/logout", RequireAccount(db, LogoutHandler(db)))
mux.Handle("/account", RequireAccount(db, AccountHandler(db)))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", RequireAccount(db, http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", RequireAccount(db, http.StripPrefix("/artist", serveArtist())))
mux.Handle("/track/", RequireAccount(db, http.StripPrefix("/track", serveTrack())))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
account, err := controller.GetAccountByRequest(db, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
}
if account == nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
releases, err := controller.GetAllReleases(app.DB, false, 3, true)
releases, err := controller.GetAllReleases(db, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releaseCount, err := controller.GetReleaseCount(app.DB, false)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(app.DB)
artists, err := controller.GetAllArtists(db)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artistCount, err := controller.GetArtistCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
tracks, err := controller.GetOrphanTracks(db)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
trackCount, err := controller.GetTrackCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
adminPageData
Releases []*model.Release
ReleaseCount int
Artists []*model.Artist
ArtistCount int
Tracks []*model.Track
TrackCount int
Account *model.Account
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
err = templates.IndexTemplate.Execute(w, IndexData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = pages["index"].Execute(w, IndexData{
Account: account,
Releases: releases,
ReleaseCount: releaseCount,
Artists: artists,
ArtistCount: artistCount,
Tracks: tracks,
TrackCount: trackCount,
})
if err != nil {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}))
return mux
}
func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account, err := controller.GetAccountByRequest(db, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render := func() {
err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure invite code exists in DB
invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
controller.SetSessionError(app.DB, session, "Invalid invite code.")
render()
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: sql.NullString{ String: credentials.Email, Valid: true },
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
render()
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
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))
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
}
// registration success!
controller.SetSessionAccount(app.DB, session, &account)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
render := func() {
err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render()
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("username") || !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
err = controller.SetSessionAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
render := func() {
err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
r.ParseForm()
if !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totpCode := r.FormValue("totp")
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))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func logoutHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Expires: time.Now(),
Path: "/",
})
err = templates.LogoutTemplate.Execute(w, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func requireAccount(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account == nil {
// TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
/*
//go:embed "static"
var staticFS embed.FS
ctx := context.WithValue(r.Context(), "account", account)
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uri := strings.TrimPrefix(r.URL.Path, "/static")
file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri)))
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
w.WriteHeader(http.StatusOK)
w.Write(file)
})
}
*/
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session == nil {
// create a new session
session, err = controller.CreateSession(app.DB, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Value: session.Token,
Expires: session.ExpiresAt,
Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
HttpOnly: true,
Path: "/",
})
}
ctx := context.WithValue(r.Context(), "session", session)
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
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}

View file

@ -1,68 +0,0 @@
package admin
import (
"arimelody-web/admin/templates"
"arimelody-web/log"
"arimelody-web/model"
"fmt"
"net/http"
"os"
"strings"
)
func logsHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
levelFilter := []log.LogLevel{}
typeFilter := []string{}
query := r.URL.Query().Get("q")
for key, value := range r.URL.Query() {
if strings.HasPrefix(key, "level-") && value[0] == "on" {
m := map[string]log.LogLevel{
"info": log.LEVEL_INFO,
"warn": log.LEVEL_WARN,
}
level, ok := m[strings.TrimPrefix(key, "level-")]
if ok {
levelFilter = append(levelFilter, level)
}
continue
}
if strings.HasPrefix(key, "type-") && value[0] == "on" {
typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-")))
continue
}
}
logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type LogsResponse struct {
adminPageData
Logs []*log.Log
}
err = templates.LogsTemplate.Execute(w, LogsResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Logs: logs,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}

View file

@ -3,83 +3,41 @@ package admin
import (
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin/templates"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveReleases(app *model.AppState) http.Handler {
func serveRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0]
var action string = ""
if len(slices) > 1 {
action = slices[1]
}
account := r.Context().Value("account").(*model.Account)
if len(releaseID) > 0 {
serveRelease(app, releaseID, action).ServeHTTP(w, r)
return
}
type ReleasesData struct {
adminPageData
Releases []*model.Release
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
adminPageData: adminPageData{
Path: r.URL.Path,
Session: session,
},
Releases: releases,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true)
release, err := controller.GetRelease(global.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if len(action) > 0 {
switch action {
if len(slices) > 1 {
switch slices[1] {
case "editcredits":
serveEditCredits(release).ServeHTTP(w, r)
return
case "addcredit":
serveAddCredit(app, release).ServeHTTP(w, r)
serveAddCredit(release).ServeHTTP(w, r)
return
case "newcredit":
serveNewCredit(app).ServeHTTP(w, r)
serveNewCredit().ServeHTTP(w, r)
return
case "editlinks":
serveEditLinks(release).ServeHTTP(w, r)
@ -88,10 +46,10 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
serveEditTracks(release).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(app, release).ServeHTTP(w, r)
serveAddTrack(release).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack(app).ServeHTTP(w, r)
serveNewTrack().ServeHTTP(w, r)
return
}
http.NotFound(w, r)
@ -99,20 +57,16 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
}
type ReleaseResponse struct {
adminPageData
Account *model.Account
Release *model.Release
}
for i, track := range release.Tracks {
track.Number = i + 1
}
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
err = pages["release"].Execute(w, ReleaseResponse{
Account: account,
Release: release,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -121,19 +75,19 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release)
err := components["editcredits"].Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
func serveAddCredit(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -144,23 +98,23 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.AddCreditTemplate.Execute(w, response{
err = components["addcredit"].Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
if err != nil {
fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveNewCredit(app *model.AppState) http.Handler {
func serveNewCredit() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID)
artist, err := controller.GetArtist(global.DB, artistID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -170,9 +124,9 @@ func serveNewCredit(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist)
err = components["newcredit"].Execute(w, artist)
if err != nil {
fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -181,9 +135,9 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditLinksTemplate.Execute(w, release)
err := components["editlinks"].Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -192,22 +146,19 @@ func serveEditLinks(release *model.Release) http.Handler {
func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
type editTracksData struct { Release *model.Release }
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
err := components["edittracks"].Execute(w, release)
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)
}
})
}
func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
func serveAddTrack(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -218,23 +169,24 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.AddTrackTemplate.Execute(w, response{
err = components["addtrack"].Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
if err != nil {
fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
})
}
func serveNewTrack(app *model.AppState) http.Handler {
func serveNewTrack() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(app.DB, trackID)
track, err := controller.GetTrack(global.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -244,10 +196,11 @@ func serveNewTrack(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track)
err = components["newtrack"].Execute(w, track)
if err != nil {
fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
})
}

View file

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

View file

@ -69,28 +69,3 @@ export function makeMagicList(container, itemSelector, callback) {
if (callback) callback();
});
}
export function hijackClickEvent(container, link) {
container.addEventListener('click', event => {
if (event.target.tagName.toLowerCase() === 'a') return;
event.preventDefault();
link.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
button: event.button,
}));
});
}
document.addEventListener("readystatechange", () => {
const navbar = document.getElementById("navbar");
document.getElementById("toggle-nav").addEventListener("click", event => {
event.preventDefault();
navbar.classList.toggle("open");
})
});

View file

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

View file

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

View file

@ -1,21 +1,28 @@
@import url("/admin/static/index.css");
div.card {
margin-bottom: 2rem;
form#change-password {
width: 100%;
display: flex;
flex-direction: column;
align-items: start;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
label {
width: auto;
margin: 0;
display: flex;
align-items: center;
color: inherit;
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
form#change-password input,
form#delete-account input {
width: 20em;
min-width: auto;
max-width: calc(100% - 1em - 2px);
input {
width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
@ -26,16 +33,21 @@ form#delete-account input {
color: inherit;
}
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
.mfa-device {
padding: .75em;
background: #f8f8f8f8;
border: 1px solid #808080;
border-radius: .5em;
margin-bottom: .5em;
display: flex;
justify-content: space-between;
color: var(--fg-3);
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
}
.mfa-device div {
@ -51,14 +63,3 @@ form#delete-account input {
.mfa-device .mfa-device-name {
font-weight: bold;
}
.mfa-device form input {
display: none !important;
}
.mfa-actions {
display: flex;
flex-direction: row;
gap: .5em;
flex-wrap: wrap;
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,100 @@
@import url("/admin/static/release-list-item.css");
.create-btn {
background: #c4ff6a;
padding: .5em .8em;
border-radius: .5em;
border: 1px solid #84b141;
text-decoration: none;
}
.create-btn:hover {
background: #fff;
border-color: #d0d0d0;
text-decoration: inherit;
}
.create-btn:active {
background: #d0d0d0;
border-color: #808080;
text-decoration: inherit;
}
.artist {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: .5em;
border-radius: .5em;
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: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.track p {
margin: 0;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}

View file

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

View file

@ -1,107 +0,0 @@
main {
width: min(1080px, calc(100% - 2em))!important
}
form#search-form {
width: calc(100% - 2em);
margin: 1em 0;
padding: 1em;
border-radius: 16px;
color: var(--fg-0);
background: var(--bg-2);
box-shadow: var(--shadow-md);
}
div#search {
display: flex;
}
#search input {
margin: 0;
padding: .3em .8em;
flex-grow: 1;
border: none;
border-radius: 16px;
color: var(--fg-1);
background: var(--bg-0);
box-shadow: var(--shadow-sm);
}
#search button {
margin-left: .5em;
padding: 0 .8em;
}
form #filters p {
margin: .5em 0 0 0;
}
form #filters label {
color: inherit;
display: inline;
}
form #filters input {
margin-right: 1em;
display: inline;
}
#logs {
width: 100%;
overflow: scroll;
}
@media screen and (max-width: 720px) {
#logs {
font-size: 12px;
}
}
#logs table{
border-collapse: collapse;
}
#logs tr {
}
#logs tr td {
border-bottom: 1px solid #8888;
}
#logs tr td:nth-child(even) {
background: #00000004;
}
#logs th, #logs td {
padding: .4em .8em;
}
#logs .log {
color: var(--fg-2);
}
td, th {
width: 1%;
text-align: left;
white-space: nowrap;
}
td.log-level,
th.log-level,
td.log-type,
th.log-type {
text-align: center;
}
td.log-content,
td.log-content {
width: 100%;
white-space: collapse;
}
#logs .log:hover {
background: color-mix(in srgb, var(--fg-3) 10%, transparent);
}
#logs .log.warn {
color: var(--col-on-warn);
background: var(--col-warn);
}
#logs .log.warn:hover {
background: var(--col-warn-hover);
}

View file

@ -0,0 +1,87 @@
.release {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: row;
gap: 1em;
border-radius: .5em;
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: .5em;
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: .3em;
background: #e0e0e0;
transition: color .1s, background .1s;
}
.release-actions a:hover {
color: #303030;
background: #f0f0f0;
text-decoration: none;
}

View file

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

View file

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

65
admin/templates.go Normal file
View file

@ -0,0 +1,65 @@
package admin
import (
"html/template"
"path/filepath"
)
var pages = map[string]*template.Template{
"index": 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"),
)),
"login": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login.html"),
)),
"create-account": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "create-account.html"),
)),
"logout": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"),
)),
"account": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"),
)),
"release": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"),
)),
"artist": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"),
)),
"track": 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 components = map[string]*template.Template{
"editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))),
"addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))),
"newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))),
"editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))),
"edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))),
"addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))),
"newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))),
}

View file

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

View file

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

View file

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

View file

@ -1,91 +0,0 @@
{{define "head"}}
<title>Account Settings - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-account.css">
{{end}}
{{define "content"}}
<main>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card">
<div class="card-header">
<h2>Change Password</h2>
</div>
<form action="/admin/account/password" method="POST" id="change-password">
<label for="current-password">Current Password</label>
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
<label for="new-password">New Password</label>
<input type="password" id="new-password" name="new-password" value="" autocomplete="new-password" required>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
<br>
<button type="submit" class="save">Change Password</button>
</form>
</div>
<div class="card mfa-devices">
<div class="card-header">
<h2>MFA Devices</h2>
</div>
{{if .TOTPs}}
{{range .TOTPs}}
<div class="mfa-device">
<div>
<p class="mfa-device-name">{{.TOTP.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div>
<div>
<form method="POST" action="/admin/account/totp-delete">
<input type="text" name="totp-name" value="{{.TOTP.Name}}" hidden>
<button type="submit" class="delete">Delete</button>
</form>
</div>
</div>
{{end}}
{{else}}
<p>You have no MFA devices.</p>
{{end}}
<div class="mfa-actions">
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
</div>
</div>
<div class="card danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will need to enter your password and TOTP below.
</p>
<form action="/admin/account/delete" method="POST" id="delete-account">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<br>
<button type="submit" class="delete">Delete Account</button>
</form>
</div>
</main>
<script type="module" src="/admin/static/edit-account.js" defer></script>
{{end}}

View file

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

View file

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

View file

@ -1,53 +0,0 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<style>
form#login-totp {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
form input {
width: calc(100% - 1rem - 2px) !important;
}
@media screen and (max-width: 720px) {
h1 {
margin-top: 3em;
text-align: center;
}
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/totp" method="POST" id="login-totp">
<h1>Two-Factor Authentication</h1>
<div>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

@ -1,56 +0,0 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<style>
@media screen and (max-width: 720px) {
h1 {
margin-top: 3em;
text-align: center;
}
}
form#login {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
form input {
width: calc(100% - 1rem - 2px) !important;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/login" method="POST" id="login">
<h1>Log In</h1>
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

@ -1,69 +0,0 @@
{{define "head"}}
<title>Audit Logs - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/logs.css">
{{end}}
{{define "content"}}
<main>
<h1>Audit Logs</h1>
<form action="/admin/logs" method="GET" id="search-form">
<div id="search">
<input type="text" name="q" value="" placeholder="Filter by message...">
<button type="submit" class="save">Search</button>
</div>
<div id="filters">
<div>
<p>Level:</p>
<label for="level-info">Info</label>
<input type="checkbox" name="level-info" id="level-info">
<label for="level-warn">Warning</label>
<input type="checkbox" name="level-warn" id="level-warn">
</div>
<div>
<p>Type:</p>
<label for="type-account">Account</label>
<input type="checkbox" name="type-account" id="type-account">
<label for="type-music">Music</label>
<input type="checkbox" name="type-music" id="type-music">
<label for="type-artist">Artist</label>
<input type="checkbox" name="type-artist" id="type-artist">
<label for="type-blog">Blog</label>
<input type="checkbox" name="type-blog" id="type-blog">
<label for="type-artwork">Artwork</label>
<input type="checkbox" name="type-artwork" id="type-artwork">
<label for="type-files">Files</label>
<input type="checkbox" name="type-files" id="type-files">
<label for="type-misc">Misc</label>
<input type="checkbox" name="type-misc" id="type-misc">
</div>
</div>
</form>
<hr>
<div id="logs">
<table>
<thead>
<tr>
<th class="log-time">Time</th>
<th class="log-level">Level</th>
<th class="log-type">Type</th>
<th class="log-content">Message</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr class="log {{toLower (parseLevel .Level)}}">
<td class="log-time">{{prettyTime .CreatedAt}}</td>
<td class="log-level">{{parseLevel .Level}}</td>
<td class="log-type">{{titleCase .Type}}</td>
<td class="log-content">{{.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</main>
{{end}}

View file

@ -1,21 +0,0 @@
{{define "prideflag"}}
<a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>
</a>
{{end}}

View file

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

View file

@ -1,63 +0,0 @@
{{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<style>
.qr-code {
border: 1px solid #8888;
}
code {
user-select: all;
}
#totp-setup input {
width: 3.8em;
min-width: auto;
font-size: 32px;
font-family: 'Monaspace Argon', monospace;
text-align: center;
}
</style>
{{end}}
{{define "content"}}
<main>
<h1>Two-Factor Authentication</h1>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
{{if .QRBase64Image}}
<img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code">
<p>
Scan the QR code above into your authentication app or password manager,
then enter your 2FA code below.
</p>
<p>
If the QR code does not work, you may also enter this secret code:
</p>
{{else}}
<p>
Paste the below secret code into your authentication app or password manager,
then enter your 2FA code below:
</p>
{{end}}
<p><code>{{.TOTP.Secret}}</code></p>
<label for="totp">TOTP:</label>
<input type="text"
name="totp"
value=""
minlength="6"
maxlength="6"
autocomplete="one-time-code"
required
autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -1,21 +0,0 @@
{{define "head"}}
<title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
{{end}}
{{define "content"}}
<main>
<h1>Two-Factor Authentication</h1>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-setup" method="POST" id="totp-setup">
<label for="totp-name">TOTP Device Name:</label>
<input type="text" name="totp-name" value="" autocomplete="off" required autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
{{define "head"}}
<title>Register - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
p a {
color: #2a67c8;
@ -10,7 +11,7 @@ a.discord {
color: #5865F2;
}
form#register {
form {
width: 100%;
display: flex;
flex-direction: column;
@ -25,33 +26,45 @@ form button {
margin-top: 1rem;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input {
width: calc(100% - 1rem - 2px);
width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{end}}
<form action="/admin/register" method="POST" id="register">
<h1>Create Account</h1>
<form action="/admin/register" method="POST" id="create-account">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<input type="text" name="username" value="">
<label for="email">Email</label>
<input type="text" name="email" value="" autocomplete="email" required>
<input type="text" name="email" value="">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="new-password" required>
<input type="password" name="password" value="">
<label for="invite">Invite Code</label>
<input type="text" name="invite" value="" autocomplete="off" required>
<input type="text" name="invite" value="">
</div>
<button type="submit" class="new">Create Account</button>

View file

@ -0,0 +1,69 @@
{{define "head"}}
<title>Account Settings - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-account.css">
{{end}}
{{define "content"}}
<main>
<h1>Account Settings ({{.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card">
<form action="/api/v1/change-password" method="POST" id="change-password">
<div>
<label for="current-password">Current Password</label>
<input type="password" name="current-password" value="" autocomplete="current-password">
<label for="new-password">Password</label>
<input type="password" name="new-password" value="" autocomplete="new-password">
<label for="confirm-password">Confirm Password</label>
<input type="password" name="confirm-password" value="" autocomplete="new-password">
</div>
<button type="submit" class="save">Change Password</button>
</form>
</div>
<div class="card-title">
<h2>MFA Devices</h2>
</div>
<div class="card mfa-devices">
{{if .TOTPs}}
{{range .TOTPs}}
<div class="mfa-device">
<div>
<p class="mfa-device-name">{{.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAt}}</p>
</div>
<div>
<a class="delete">Delete</a>
</div>
</div>
{{end}}
{{else}}
<p>You have no MFA devices.</p>
{{end}}
<button type="submit" class="new" id="add-mfa-device">Add MFA Device</button>
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Account</button>
</div>
</main>
<script type="module" src="/admin/static/edit-account.js" defer></script>
{{end}}

View file

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

View file

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

View file

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

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

@ -0,0 +1,72 @@
{{define "head"}}
<title>Admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/index.css">
{{end}}
{{define "content"}}
<main>
<div class="card-title">
<h1>Releases</h1>
<a class="create-btn" 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="create-btn" 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="create-btn" 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}}

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

@ -0,0 +1,44 @@
<!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>
<div class="flex-fill"></div>
{{if .Account}}
<div class="nav-item">
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. 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>

86
admin/views/login.html Normal file
View file

@ -0,0 +1,86 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
p a {
color: #2a67c8;
}
a.discord {
color: #5865F2;
}
form {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input {
width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{end}}
{{if .Token}}
<meta http-equiv="refresh" content="0;url=/admin/" />
<p>
Logged in successfully.
You should be redirected to <a href="/admin">/admin</a> soon.
</p>
{{else}}
<form action="/admin/login" method="POST" id="login">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password">
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code">
</div>
<button type="submit" class="save">Login</button>
</form>
{{end}}
</main>
{{end}}

View file

@ -12,10 +12,13 @@ p a {
{{define "content"}}
<main>
<meta http-equiv="refresh" content="0;url=/admin/login" />
<meta http-equiv="refresh" content="5;url=/" />
<p>
Logged out successfully.
You should be redirected to <a href="/admin/login">/admin/login</a> shortly.
You should be redirected to <a href="/">/</a> in 5 seconds.
<script>
localStorage.removeItem("arime-token");
</script>
</p>
</main>

202
api/account.go Normal file
View file

@ -0,0 +1,202 @@
package api
import (
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/global"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func handleLogin() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
credentials := LoginRequest{}
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
err = json.NewEncoder(w).Encode(LoginResponse{
Token: token.Token,
ExpiresAt: token.ExpiresAt,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func handleAccountRegistration() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{}
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
// make sure code exists in DB
invite, err := controller.GetInvite(global.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if invite == nil {
http.Error(w, "Invalid invite code", http.StatusBadRequest)
return
}
if time.Now().After(invite.ExpiresAt) {
err := controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
http.Error(w, "Invalid invite code", http.StatusBadRequest)
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: credentials.Email,
AvatarURL: "/img/default-avatar.png",
}
err = controller.CreateAccount(global.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
http.Error(w, "An account with that username already exists", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
err = json.NewEncoder(w).Encode(LoginResponse{
Token: token.Token,
ExpiresAt: token.ExpiresAt,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func handleDeleteAccount() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
credentials := LoginRequest{}
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "Invalid password", http.StatusBadRequest)
return
}
// TODO: check TOTP
err = controller.DeleteAccount(global.DB, account.Username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Account deleted successfully\n"))
})
}

View file

@ -1,32 +1,44 @@
package api
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"fmt"
"net/http"
"strings"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/admin"
"arimelody-web/controller"
"github.com/jmoiron/sqlx"
)
func Handler(app *model.AppState) http.Handler {
func Handler(db *sqlx.DB) http.Handler {
mux := http.NewServeMux()
// ACCOUNT ENDPOINTS
/*
// temporarily disabling these
// accounts should really be handled via the frontend rn, and juggling
// two different token bearer methods kinda sucks!!
// i'll look into generating API tokens on the frontend in the future
// TODO: generate API keys on the frontend
mux.Handle("/v1/login", handleLogin())
mux.Handle("/v1/register", handleAccountRegistration())
mux.Handle("/v1/delete-account", handleDeleteAccount())
*/
// ARTIST ENDPOINTS
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artistID = strings.Split(r.URL.Path[1:], "/")[0]
artist, err := controller.GetArtist(app.DB, artistID)
artist, err := controller.GetArtist(db, artistID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -34,13 +46,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/artist/{id}
ServeArtist(app, artist).ServeHTTP(w, r)
ServeArtist(artist).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/artist/{id} (admin)
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
admin.RequireAccount(db, UpdateArtist(artist)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin)
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
admin.RequireAccount(db, DeleteArtist(artist)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -49,10 +61,10 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/artist
ServeAllArtists(app).ServeHTTP(w, r)
ServeAllArtists().ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/artist (admin)
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
admin.RequireAccount(db, CreateArtist()).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -62,13 +74,13 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
release, err := controller.GetRelease(app.DB, releaseID, true)
release, err := controller.GetRelease(db, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -76,13 +88,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/music/{id}
ServeRelease(app, release).ServeHTTP(w, r)
ServeRelease(release).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/music/{id} (admin)
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
admin.RequireAccount(db, UpdateRelease(release)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin)
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
admin.RequireAccount(db, DeleteRelease(release)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -91,10 +103,10 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/music
ServeCatalog(app).ServeHTTP(w, r)
ServeCatalog().ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/music (admin)
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
admin.RequireAccount(db, CreateRelease()).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -104,13 +116,13 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackID = strings.Split(r.URL.Path[1:], "/")[0]
track, err := controller.GetTrack(app.DB, trackID)
track, err := controller.GetTrack(db, trackID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -118,13 +130,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track/{id} (admin)
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
admin.RequireAccount(db, ServeTrack(track)).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/track/{id} (admin)
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
admin.RequireAccount(db, UpdateTrack(track)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin)
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
admin.RequireAccount(db, DeleteTrack(track)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -133,66 +145,14 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track (admin)
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
admin.RequireAccount(db, ServeAllTracks()).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/track (admin)
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
admin.RequireAccount(db, CreateTrack()).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := getSession(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to get session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), "session", session)
mux.ServeHTTP(w, r.WithContext(ctx))
})
}
func requireAccount(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session == nil || session.Account == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
var token string
// check cookies first
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, fmt.Errorf("Failed to retrieve session cookie: %v\n", err)
}
if sessionCookie != nil {
token = sessionCookie.Value
} else {
// check Authorization header
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if token == "" { return nil, nil }
// fetch existing session
session, err := controller.GetSession(app.DB, token)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v\n", err)
}
if session != nil {
// TODO: consider running security checks here (i.e. user agent mismatches)
}
return session, nil
return mux
}

View file

@ -1,66 +1,71 @@
package api
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
func ServeAllArtists(app *model.AppState) http.Handler {
func ServeAllArtists() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artists = []*model.Artist{}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
artists, err := controller.GetAllArtists(global.DB)
if err != nil {
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(artists)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type (
creditJSON struct {
func ServeArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type (
creditJSON struct {
ID string `json:"id"`
Title string `json:"title"`
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
Artwork string `json:"artwork"`
Role string `json:"role"`
Primary bool `json:"primary"`
}
artistJSON struct {
*model.Artist
Credits map[string]creditJSON `json:"credits"`
}
)
Role string `json:"role"`
Primary bool `json:"primary"`
}
artistJSON struct {
*model.Artist
Credits map[string]creditJSON `json:"credits"`
}
)
session := r.Context().Value("session").(*model.Session)
show_hidden_releases := session != nil && session.Account != nil
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
show_hidden_releases := account != nil
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
if err != nil {
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
var credits = map[string]creditJSON{}
for _, credit := range dbCredits {
@ -74,23 +79,21 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
}
}
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(artistJSON{
Artist: artist,
Credits: credits,
})
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func CreateArtist(app *model.AppState) http.Handler {
func CreateArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
@ -104,7 +107,7 @@ func CreateArtist(app *model.AppState) http.Handler {
}
if artist.Name == "" { artist.Name = artist.ID }
err = controller.CreateArtist(app.DB, &artist)
err = controller.CreateArtist(global.DB, &artist)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
@ -115,16 +118,12 @@ func CreateArtist(app *model.AppState) http.Handler {
return
}
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username)
w.WriteHeader(http.StatusCreated)
})
}
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
func UpdateArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
fmt.Printf("WARN: Failed to update artist: %s\n", err)
@ -137,7 +136,7 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
} else {
if strings.Contains(artist.Avatar, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "avatar")
filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID)
filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID)
// clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
@ -156,7 +155,7 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
}
}
err = controller.UpdateArtist(app.DB, artist)
err = controller.UpdateArtist(global.DB, artist)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -165,16 +164,12 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
})
}
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
func DeleteArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteArtist(app.DB, artist.ID)
err := controller.DeleteArtist(global.DB, artist.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -183,7 +178,5 @@ func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
})
}

View file

@ -1,33 +1,32 @@
package api
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
func ServeRelease(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases
privileged := false
if !release.Visible {
session, err := controller.GetSessionFromRequest(app, r)
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session != nil && session.Account != nil {
if account != nil {
// TODO: check privilege on release
privileged = true
}
@ -68,14 +67,14 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
if release.IsReleased() || privileged {
// get credits
credits, err := controller.GetReleaseCredits(app.DB, release.ID)
credits, err := controller.GetReleaseCredits(global.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, credit := range credits {
artist, err := controller.GetArtist(app.DB, credit.Artist.ID)
artist, err := controller.GetArtist(global.DB, credit.Artist.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -90,7 +89,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
}
// get tracks
tracks, err := controller.GetReleaseTracks(app.DB, release.ID)
tracks, err := controller.GetReleaseTracks(global.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -105,7 +104,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
}
// get links
links, err := controller.GetReleaseLinks(app.DB, release.ID)
links, err := controller.GetReleaseLinks(global.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -116,20 +115,20 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
}
}
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err := encoder.Encode(response)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func ServeCatalog(app *model.AppState) http.Handler {
func ServeCatalog() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -147,11 +146,16 @@ func ServeCatalog(app *model.AppState) http.Handler {
}
catalog := []Release{}
session := r.Context().Value("session").(*model.Session)
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, release := range releases {
if !release.Visible {
privileged := false
if session != nil && session.Account != nil {
if account != nil {
// TODO: check privilege on release
privileged = true
}
@ -188,9 +192,12 @@ func ServeCatalog(app *model.AppState) http.Handler {
})
}
func CreateRelease(app *model.AppState) http.Handler {
func CreateRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
@ -213,7 +220,7 @@ func CreateRelease(app *model.AppState) http.Handler {
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
err = controller.CreateRelease(app.DB, &release)
err = controller.CreateRelease(global.DB, &release)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
@ -224,8 +231,6 @@ func CreateRelease(app *model.AppState) http.Handler {
return
}
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
encoder := json.NewEncoder(w)
@ -238,10 +243,8 @@ func CreateRelease(app *model.AppState) http.Handler {
})
}
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
func UpdateRelease(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if r.URL.Path == "/" {
http.NotFound(w, r)
return
@ -252,11 +255,11 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
if len(segments) == 2 {
switch segments[1] {
case "tracks":
UpdateReleaseTracks(app, release).ServeHTTP(w, r)
UpdateReleaseTracks(release).ServeHTTP(w, r)
case "credits":
UpdateReleaseCredits(app, release).ServeHTTP(w, r)
UpdateReleaseCredits(release).ServeHTTP(w, r)
case "links":
UpdateReleaseLinks(app, release).ServeHTTP(w, r)
UpdateReleaseLinks(release).ServeHTTP(w, r)
}
return
}
@ -278,7 +281,7 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
} else {
if strings.Contains(release.Artwork, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "musicart")
filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID)
filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID)
// clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
@ -297,7 +300,7 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
}
}
err = controller.UpdateRelease(app.DB, release)
err = controller.UpdateRelease(global.DB, release)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -306,15 +309,11 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
func UpdateReleaseTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
if err != nil {
@ -322,7 +321,7 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
return
}
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -331,15 +330,11 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
func UpdateReleaseCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type creditJSON struct {
Artist string
Role string
@ -363,7 +358,7 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
})
}
err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
err = controller.UpdateReleaseCredits(global.DB, release.ID, credits)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
@ -376,14 +371,15 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
func UpdateReleaseLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if r.Method != http.MethodPut {
http.NotFound(w, r)
return
}
var links = []*model.Link{}
err := json.NewDecoder(r.Body).Decode(&links)
@ -392,7 +388,7 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
return
}
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
err = controller.UpdateReleaseLinks(global.DB, release.ID, links)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -401,16 +397,12 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
func DeleteRelease(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteRelease(app.DB, release.ID)
err := controller.DeleteRelease(global.DB, release.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -419,7 +411,5 @@ func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
})
}

View file

@ -1,13 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"encoding/json"
"fmt"
"net/http"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
type (
@ -17,7 +17,7 @@ type (
}
)
func ServeAllTracks(app *model.AppState) http.Handler {
func ServeAllTracks() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type Track struct {
ID string `json:"id"`
@ -26,10 +26,10 @@ func ServeAllTracks(app *model.AppState) http.Handler {
var tracks = []Track{}
var dbTracks = []*model.Track{}
dbTracks, err := controller.GetAllTracks(app.DB)
dbTracks, err := controller.GetAllTracks(global.DB)
if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
for _, track := range dbTracks {
@ -39,23 +39,23 @@ func ServeAllTracks(app *model.AppState) http.Handler {
})
}
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(tracks)
if err != nil {
if err != nil {
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
func ServeTrack(track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false)
if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
releases := []string{}
@ -63,20 +63,23 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
releases = append(releases, release.ID)
}
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(Track{ track, releases })
if err != nil {
if err != nil {
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func CreateTrack(app *model.AppState) http.Handler {
func CreateTrack() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var track model.Track
err := json.NewDecoder(r.Body).Decode(&track)
@ -90,30 +93,26 @@ func CreateTrack(app *model.AppState) http.Handler {
return
}
id, err := controller.CreateTrack(app.DB, &track)
id, err := controller.CreateTrack(global.DB, &track)
if err != nil {
fmt.Printf("WARN: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(id))
})
}
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
func UpdateTrack(track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
if r.Method != http.MethodPut || r.URL.Path == "/" {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -125,15 +124,13 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return
}
err = controller.UpdateTrack(app.DB, track)
err = controller.UpdateTrack(global.DB, track)
if err != nil {
fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
@ -144,22 +141,18 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
})
}
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
func DeleteTrack(track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
if r.Method != http.MethodDelete || r.URL.Path == "/" {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(app.DB, trackID)
err := controller.DeleteTrack(global.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
})
}

View file

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

9
bundle.sh Executable file
View 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/

View file

@ -1,10 +1,14 @@
package controller
import (
"arimelody-web/model"
"strings"
"arimelody-web/global"
"arimelody-web/model"
"errors"
"fmt"
"net/http"
"strings"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
@ -18,21 +22,7 @@ func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
return accounts, nil
}
func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) {
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
@ -60,12 +50,12 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
return &account, nil
}
func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) {
if sessionToken == "" { return nil, nil }
func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
if token == "" { return nil, nil }
account := model.Account{}
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
@ -76,6 +66,42 @@ func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, erro
return &account, nil
}
func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(tokenStr) > 0 {
return tokenStr
}
cookie, err := r.Cookie(global.COOKIE_TOKEN)
if err != nil {
return ""
}
return cookie.Value
}
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
tokenStr := GetTokenFromRequest(db, r)
token, err := GetToken(db, tokenStr)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, errors.New("GetToken: " + err.Error())
}
// does user-agent match the token?
if r.UserAgent() != token.UserAgent {
// invalidate the token
DeleteToken(db, tokenStr)
fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent())
// TODO: log unauthorised activity to the user
return nil, errors.New("User agent mismatch")
}
return GetAccountByToken(db, tokenStr)
}
func CreateAccount(db *sqlx.DB, account *model.Account) error {
err := db.Get(
&account.ID,
@ -94,7 +120,7 @@ func CreateAccount(db *sqlx.DB, account *model.Account) error {
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec(
"UPDATE account " +
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
"WHERE id=$1",
account.ID,
account.Username,
@ -106,30 +132,7 @@ func UpdateAccount(db *sqlx.DB, account *model.Account) error {
return err
}
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
failAttempts := 0
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
if err != nil { return false, err }
locked := false
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
err = LockAccount(db, accountID)
if err != nil { return false, err }
locked = true
}
return locked, err
}
func LockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
return err
}
func UnlockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
func DeleteAccount(db *sqlx.DB, username string) error {
_, err := db.Exec("DELETE FROM account WHERE username=$1", username)
return err
}

View file

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

View file

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

View file

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

View file

@ -1,23 +0,0 @@
package controller
import (
"arimelody-web/model"
"net/http"
"slices"
"strings"
)
// Returns the request's original IP address, resolving the `x-forwarded-for`
// header if the request originates from a trusted proxy.
func ResolveIP(app *model.AppState, r *http.Request) string {
addr := strings.Split(r.RemoteAddr, ":")[0]
if slices.Contains(app.Config.TrustedProxies, addr) {
forwardedFor := r.Header.Get("x-forwarded-for")
if len(forwardedFor) > 0 {
// discard extra IPs; cloudflare tends to append their nodes
forwardedFor = strings.Split(forwardedFor, ", ")[0]
return forwardedFor
}
}
return addr
}

View file

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

View file

@ -1,52 +0,0 @@
package controller
import (
"encoding/base64"
// "image"
// "image/color"
"github.com/skip2/go-qrcode"
)
func GenerateQRCode(data string) (string, error) {
imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256)
if err != nil {
return "", err
}
base64Img := base64.StdEncoding.EncodeToString(imgBytes)
return base64Img, nil
}
// vvv DEPRECATED vvv
// const margin = 4
//
// type QRCodeECCLevel int64
// const (
// LOW QRCodeECCLevel = iota
// MEDIUM
// QUARTILE
// HIGH
// )
//
// func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
// for yi := range 7 {
// for xi := range 7 {
// if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
// img.Set(x + xi, y + yi, color.Black)
// } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
// img.Set(x + xi, y + yi, color.Black)
// }
// }
// }
// }
//
// func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
// for yi := range 5 {
// for xi := range 5 {
// if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
// img.Set(x + xi, y + yi, color.Black)
// }
// }
// }
// img.Set(x + 2, y + 2, color.Black)
// }

View file

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

View file

@ -1,12 +0,0 @@
-- Audit logs
CREATE TABLE arimelody.auditlog (
id UUID DEFAULT gen_random_uuid(),
level int NOT NULL DEFAULT 0,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
-- Need moar timestamps
ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp;
ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL;

View file

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

View file

@ -1,193 +0,0 @@
package controller
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"arimelody-web/log"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err)
}
var session *model.Session
if sessionCookie != nil {
// fetch existing session
session, err = GetSession(app.DB, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v", err)
}
if session != nil {
if session.UserAgent != r.UserAgent() {
msg := "Session user agent mismatch. A cookie may have been hijacked!"
if session.Account != nil {
account, _ := GetAccountByID(app.DB, session.Account.ID)
msg += " (Account \"" + account.Username + "\")"
}
app.Log.Warn(log.TYPE_ACCOUNT, msg)
err = DeleteSession(app.DB, session.Token)
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
}
return nil, nil
}
}
}
return session, nil
}
func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
tokenString := GenerateAlnumString(TOKEN_LEN)
session := model.Session{
Token: string(tokenString),
UserAgent: userAgent,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24),
}
_, err := db.Exec("INSERT INTO session " +
"(token, user_agent, created_at, expires_at) VALUES " +
"($1, $2, $3, $4)",
session.Token,
session.UserAgent,
session.CreatedAt,
session.ExpiresAt,
)
if err != nil {
return nil, err
}
return &session, nil
}
// func WriteSession(db *sqlx.DB, session *model.Session) error {
// _, err := db.Exec(
// "UPDATE session " +
// "SET account=$2,message=$3,error=$4 " +
// "WHERE token=$1",
// session.Token,
// session.Account.ID,
// session.Message,
// session.Error,
// )
// return err
// }
func SetSessionAttemptAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
var err error
session.AttemptAccount = account
if account == nil {
_, err = db.Exec("UPDATE session SET attempt_account=NULL WHERE token=$1", session.Token)
} else {
_, err = db.Exec("UPDATE session SET attempt_account=$2 WHERE token=$1", session.Token, account.ID)
}
return err
}
func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
var err error
session.Account = account
if account == nil {
_, err = db.Exec("UPDATE session SET account=NULL WHERE token=$1", session.Token)
} else {
_, err = db.Exec("UPDATE session SET account=$2 WHERE token=$1", session.Token, account.ID)
}
return err
}
func SetSessionMessage(db *sqlx.DB, session *model.Session, message string) error {
var err error
if message == "" {
if !session.Message.Valid { return nil }
session.Message = sql.NullString{ }
_, err = db.Exec("UPDATE session SET message=NULL WHERE token=$1", session.Token)
} else {
session.Message = sql.NullString{ String: message, Valid: true }
_, err = db.Exec("UPDATE session SET message=$2 WHERE token=$1", session.Token, message)
}
return err
}
func SetSessionError(db *sqlx.DB, session *model.Session, message string) error {
var err error
if message == "" {
if !session.Error.Valid { return nil }
session.Error = sql.NullString{ }
_, err = db.Exec("UPDATE session SET error=NULL WHERE token=$1", session.Token)
} else {
session.Error = sql.NullString{ String: message, Valid: true }
_, err = db.Exec("UPDATE session SET error=$2 WHERE token=$1", session.Token, message)
}
return err
}
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
type dbSession struct {
model.Session
AttemptAccountID sql.NullString `db:"attempt_account"`
AccountID sql.NullString `db:"account"`
}
session := dbSession{}
err := db.Get(
&session,
"SELECT * FROM session WHERE token=$1",
token,
)
if err != nil {
return nil, err
}
if session.AccountID.Valid {
session.Account, err = GetAccountByID(db, session.AccountID.String)
if err != nil {
return nil, err
}
}
if session.AttemptAccountID.Valid {
session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String)
if err != nil {
return nil, err
}
}
return &session.Session, err
}
// func GetAllSessionsForAccount(db *sqlx.DB, accountID string) ([]model.Session, error) {
// sessions := []model.Session{}
// err := db.Select(&sessions, "SELECT * FROM session WHERE account=$1 AND expires_at>current_timestamp", accountID)
// return sessions, err
// }
func DeleteAllSessionsForAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM session WHERE account=$1", accountID)
return err
}
func DeleteSession(db *sqlx.DB, token string) error {
_, err := db.Exec("DELETE FROM session WHERE token=$1", token)
return err
}
func DeleteExpiredSessions(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM session WHERE expires_at<current_timestamp")
return err
}

61
controller/token.go Normal file
View file

@ -0,0 +1,61 @@
package controller
import (
"time"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 32
func CreateToken(db *sqlx.DB, accountID string, userAgent string) (*model.Token, error) {
tokenString := GenerateAlnumString(TOKEN_LEN)
token := model.Token{
Token: string(tokenString),
AccountID: accountID,
UserAgent: userAgent,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24),
}
_, err := db.Exec("INSERT INTO token " +
"(token, account, user_agent, created_at, expires_at) VALUES " +
"($1, $2, $3, $4, $5)",
token.Token,
token.AccountID,
token.UserAgent,
token.CreatedAt,
token.ExpiresAt,
)
if err != nil {
return nil, err
}
return &token, nil
}
func GetToken(db *sqlx.DB, token_str string) (*model.Token, error) {
token := model.Token{}
err := db.Get(&token, "SELECT * FROM token WHERE token=$1", token_str)
return &token, err
}
func GetAllTokensForAccount(db *sqlx.DB, accountID string) ([]model.Token, error) {
tokens := []model.Token{}
err := db.Select(&tokens, "SELECT * FROM token WHERE account=$1 AND expires_at>current_timestamp", accountID)
return tokens, err
}
func DeleteAllTokensForAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM token WHERE account=$1", accountID)
return err
}
func DeleteToken(db *sqlx.DB, token string) error {
_, err := db.Exec("DELETE FROM token WHERE token=$1", token)
return err
}

View file

@ -1,25 +1,25 @@
package controller
import (
"arimelody-web/model"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"os"
"strings"
"time"
"arimelody-web/model"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"os"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const TOTP_SECRET_LENGTH = 32
const TOTP_TIME_STEP int64 = 30
const TOTP_CODE_LENGTH = 6
const TIME_STEP int64 = 30
const CODE_LENGTH = 6
func GenerateTOTP(secret string, timeStepOffset int) string {
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
@ -27,7 +27,7 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
}
counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset)
counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset)
counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
@ -37,9 +37,9 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
offset := hash[len(hash) - 1] & 0x0f
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH))
code := binaryCode % int32(math.Pow10(CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code)
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code)
}
func GenerateTOTPSecret(length int) string {
@ -58,15 +58,15 @@ func GenerateTOTPURI(username string, secret string) string {
url := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: url.QueryEscape("arimelody.space") + ":" + url.QueryEscape(username),
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
}
query := url.Query()
query.Set("secret", secret)
query.Set("issuer", "arimelody.space")
// query.Set("algorithm", "SHA1")
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
query.Set("issuer", "arimelody.me")
query.Set("algorithm", "SHA1")
query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH))
query.Set("period", fmt.Sprintf("%d", TIME_STEP))
url.RawQuery = query.Encode()
return url.String()
@ -78,7 +78,7 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
err := db.Select(
&totps,
"SELECT * FROM totp " +
"WHERE account=$1 AND confirmed=true " +
"WHERE account=$1 " +
"ORDER BY created_at ASC",
accountID,
)
@ -89,36 +89,14 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
return totps, nil
}
func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) {
totps, err := GetTOTPsForAccount(db, accountID)
if err != nil {
return nil, err
}
for _, method := range totps {
check := GenerateTOTP(method.Secret, 0)
if check == totp {
return &method, nil
}
// try again with offset- maybe user input the code late?
check = GenerateTOTP(method.Secret, 1)
if check == totp {
return &method, nil
}
}
// user failed all TOTP checks
// note: this state will still occur even if the account has no TOTP methods.
return nil, nil
}
func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
totp := model.TOTP{}
err := db.Get(
&totp,
"SELECT * FROM totp " +
"WHERE account=$1 AND name=$2",
"WHERE account=$1",
accountID,
name,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
@ -130,15 +108,6 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
return &totp, nil
}
func ConfirmTOTP(db *sqlx.DB, accountID string, name string) error {
_, err := db.Exec(
"UPDATE totp SET confirmed=true WHERE account=$1 AND name=$2",
accountID,
name,
)
return err
}
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
_, err := db.Exec(
"INSERT INTO totp (account, name, secret) " +
@ -158,8 +127,3 @@ func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
)
return err
}
func DeleteUnconfirmedTOTPs(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM totp WHERE confirmed=false")
return err
}

View file

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

View file

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

View file

@ -1,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)
}

View file

@ -1,17 +1,38 @@
package discord
import (
"arimelody-web/model"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"arimelody-web/global"
)
const API_ENDPOINT = "https://discord.com/api/v10"
var CREDENTIALS_PROVIDED = true
var CLIENT_ID = func() string {
id := global.Config.Discord.ClientID
if id == "" {
// fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided.\n")
CREDENTIALS_PROVIDED = false
}
return id
}()
var CLIENT_SECRET = func() string {
secret := global.Config.Discord.Secret
if secret == "" {
// fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided.\n")
CREDENTIALS_PROVIDED = false
}
return secret
}()
var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.Config.BaseUrl)
var REDIRECT_URI = fmt.Sprintf("https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify", CLIENT_ID, OAUTH_CALLBACK_URI)
type (
AccessTokenResponse struct {
AccessToken string `json:"access_token"`
@ -47,15 +68,15 @@ type (
}
)
func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) {
func GetOAuthTokenFromCode(code string) (string, error) {
// let's get an oauth token!
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT),
strings.NewReader(url.Values{
"client_id": {app.Config.Discord.ClientID},
"client_secret": {app.Config.Discord.Secret},
"client_id": {CLIENT_ID},
"client_secret": {CLIENT_SECRET},
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {GetOAuthCallbackURI(app.Config.BaseUrl)},
"redirect_uri": {OAUTH_CALLBACK_URI},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
@ -94,15 +115,3 @@ func GetDiscordUserFromAuth(token string) (DiscordUser, error) {
return auth_info.User, nil
}
func GetOAuthCallbackURI(baseURL string) string {
return fmt.Sprintf("%s/admin/login", baseURL)
}
func GetRedirectURI(app *model.AppState) string {
return fmt.Sprintf(
"https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify",
app.Config.Discord.ClientID,
GetOAuthCallbackURI(app.Config.BaseUrl),
)
}

View file

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

View file

@ -1,28 +1,49 @@
package controller
package global
import (
"errors"
"fmt"
"os"
"strconv"
"errors"
"fmt"
"os"
"strconv"
"arimelody-web/model"
"github.com/pelletier/go-toml/v2"
"github.com/jmoiron/sqlx"
"github.com/pelletier/go-toml/v2"
)
func GetConfig() model.Config {
type (
dbConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
Name string `toml:"name"`
User string `toml:"user"`
Pass string `toml:"pass"`
}
discordConfig struct {
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"`
DB dbConfig `toml:"db"`
Discord discordConfig `toml:"discord"`
}
)
var Config = func() config {
configFile := os.Getenv("ARIMELODY_CONFIG")
if configFile == "" {
configFile = "config.toml"
}
config := model.Config{
BaseUrl: "https://arimelody.space",
Host: "0.0.0.0",
config := config{
BaseUrl: "https://arimelody.me",
Port: 8080,
TrustedProxies: []string{ "127.0.0.1" },
DB: model.DBConfig{
DB: dbConfig{
Host: "127.0.0.1",
Port: 5432,
User: "arimelody",
@ -42,22 +63,23 @@ func GetConfig() model.Config {
err = toml.Unmarshal([]byte(data), &config)
if err != nil {
panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err))
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
os.Exit(1)
}
err = handleConfigOverrides(&config)
if err != nil {
panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err))
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
os.Exit(1)
}
return config
}
}()
func handleConfigOverrides(config *model.Config) error {
func handleConfigOverrides(config *config) error {
var err error
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }
if env, has := os.LookupEnv("ARIMELODY_HOST"); has { config.Host = env }
if env, has := os.LookupEnv("ARIMELODY_PORT"); has {
config.Port, err = strconv.ParseInt(env, 10, 0)
if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) }
@ -77,9 +99,7 @@ func handleConfigOverrides(config *model.Config) error {
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
return nil
}
var DB *sqlx.DB

3
global/const.go Normal file
View file

@ -0,0 +1,3 @@
package global
const COOKIE_TOKEN string = "AM_TOKEN"

101
global/funcs.go Normal file
View file

@ -0,0 +1,101 @@
package global
import (
"fmt"
"math/rand"
"net/http"
"strconv"
"time"
"arimelody-web/colour"
)
var PoweredByStrings = []string{
"nerd rage",
"estrogen",
"your mother",
"awesome powers beyond comprehension",
"jared",
"the weight of my sins",
"the arc reactor",
"AA batteries",
"15 euro solar panel from ebay",
"magnets, how do they work",
"a fax machine",
"dell optiplex",
"a trans girl's nintendo wii",
"BASS",
"electricity, duh",
"seven hamsters in a big wheel",
"girls",
"mzungu hosting",
"golang",
"the state of the world right now",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
"the good folks at aperture science",
"free2play CDs",
"aridoodle",
"the love of creating",
"not for the sake of art; not for the sake of money; we like painting naked people",
"30 billion dollars in VC funding",
}
func DefaultHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "arimelody.me")
w.Header().Add("Do-Not-Stab", "1")
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
w.Header().Add("X-Hacker", "spare me please")
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
w.Header().Add("X-Thinking-With", "Portals")
w.Header().Add(
"X-Powered-By",
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
)
next.ServeHTTP(w, r)
})
}
type LoggingResponseWriter struct {
http.ResponseWriter
Status int
}
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Status = status
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := LoggingResponseWriter{w, http.StatusOK}
next.ServeHTTP(&lrw, r)
after := time.Now()
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
elapsed := "<1"
if difference >= 1 {
elapsed = strconv.Itoa(difference)
}
statusColour := colour.Reset
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate),
r.Method,
r.URL.Path,
statusColour,
lrw.Status,
colour.Reset,
elapsed,
r.Header["User-Agent"][0])
})
}

7
go.mod
View file

@ -8,9 +8,4 @@ require (
)
require golang.org/x/crypto v0.27.0 // indirect
require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
)
require github.com/pelletier/go-toml/v2 v2.2.3 // indirect

8
go.sum
View file

@ -2,17 +2,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
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/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/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=

View file

@ -1,143 +0,0 @@
package log
import (
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
)
type (
Logger struct {
DB *sqlx.DB
}
Log struct {
ID string `json:"id" db:"id"`
Level LogLevel `json:"level" db:"level"`
Type string `json:"type" db:"type"`
Content string `json:"content" db:"content"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
)
const (
TYPE_ACCOUNT string = "account"
TYPE_MUSIC string = "music"
TYPE_ARTIST string = "artist"
TYPE_BLOG string = "blog"
TYPE_ARTWORK string = "artwork"
TYPE_FILES string = "files"
TYPE_MISC string = "misc"
TYPE_CURSOR string = "cursor"
)
type LogLevel int
const (
LEVEL_INFO LogLevel = 0
LEVEL_WARN LogLevel = 1
)
const DEFAULT_LOG_PAGE_LENGTH = 25
func (self *Logger) Info(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
fmt.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString)
err := createLog(self.DB, LEVEL_INFO, logType, logString)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
}
}
func (self *Logger) Warn(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString)
err := createLog(self.DB, LEVEL_WARN, logType, logString)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
}
}
func (self *Logger) Fetch(id string) (*Log, error) {
log := Log{}
err := self.DB.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
return &log, err
}
func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) {
logs := []*Log{}
params := []any{ limit, offset }
conditions := ""
if len(content) > 0 {
content = "%" + content + "%"
conditions += " WHERE content LIKE $3"
params = append(params, content)
}
if len(levelFilters) > 0 {
if len(conditions) > 0 {
conditions += " AND level IN ("
} else {
conditions += " WHERE level IN ("
}
for i := range levelFilters {
conditions += fmt.Sprintf("$%d", len(params) + 1)
if i < len(levelFilters) - 1 {
conditions += ","
}
params = append(params, levelFilters[i])
}
conditions += ")"
}
if len(typeFilters) > 0 {
if len(conditions) > 0 {
conditions += " AND type IN ("
} else {
conditions += " WHERE type IN ("
}
for i := range typeFilters {
conditions += fmt.Sprintf("$%d", len(params) + 1)
if i < len(typeFilters) - 1 {
conditions += ","
}
params = append(params, typeFilters[i])
}
conditions += ")"
}
query := fmt.Sprintf(
"SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2",
conditions,
)
/*
fmt.Printf("%s (", query)
for i, param := range params {
fmt.Print(param)
if i < len(params) - 1 {
fmt.Print(", ")
}
}
fmt.Print(")\n")
*/
err := self.DB.Select(&logs, query, params...)
if err != nil {
return nil, err
}
return logs, nil
}
func createLog(db *sqlx.DB, logLevel LogLevel, logType string, content string) error {
_, err := db.Exec(
"INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
logLevel,
logType,
content,
)
return err
}

481
main.go
View file

@ -1,93 +1,77 @@
package main
import (
"bufio"
"embed"
"errors"
"fmt"
stdLog "log"
"math"
"math/rand"
"net"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/colour"
"arimelody-web/controller"
"arimelody-web/cursor"
"arimelody-web/log"
"arimelody-web/global"
"arimelody-web/model"
"arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080
const HRT_DATE int64 = 1756478697
//go:embed "public"
var publicFS embed.FS
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
app := model.AppState{
Config: controller.GetConfig(),
Twitch: nil,
PublicFS: publicFS,
}
// TODO: refactor `global` to `AppState`
// this should contain `Config` and `DB`, and be passed through to all
// handlers that need it. it's better than weird static globals everywhere!
// initialise database connection
if app.Config.DB.Host == "" {
if global.Config.DB.Host == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
os.Exit(1)
}
if app.Config.DB.Name == "" {
if global.Config.DB.Name == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
os.Exit(1)
}
if app.Config.DB.User == "" {
if global.Config.DB.User == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
os.Exit(1)
}
if app.Config.DB.Pass == "" {
if global.Config.DB.Pass == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
os.Exit(1)
}
var err error
app.DB, err = sqlx.Connect(
global.DB, err = sqlx.Connect(
"postgres",
fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
app.Config.DB.Host,
app.Config.DB.Port,
app.Config.DB.User,
app.Config.DB.Name,
app.Config.DB.Pass,
global.Config.DB.Host,
global.Config.DB.Port,
global.Config.DB.User,
global.Config.DB.Name,
global.Config.DB.Pass,
),
)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
os.Exit(1)
}
app.DB.SetConnMaxLifetime(time.Minute * 3)
app.DB.SetMaxOpenConns(10)
app.DB.SetMaxIdleConns(10)
defer app.DB.Close()
app.Log = log.Logger{ DB: app.DB }
global.DB.SetConnMaxLifetime(time.Minute * 3)
global.DB.SetMaxOpenConns(10)
global.DB.SetMaxIdleConns(10)
defer global.DB.Close()
// handle command arguments
if len(os.Args) > 1 {
@ -101,36 +85,31 @@ func main() {
}
username := os.Args[2]
totpName := os.Args[3]
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
account, err := controller.GetAccountByUsername(app.DB, username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "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)
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP {
AccountID: account.ID,
Name: totpName,
Secret: string(secret),
}
err = controller.CreateTOTP(app.DB, &totp)
err = controller.CreateTOTP(global.DB, &totp)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
fmt.Printf("%s\n", url)
return
@ -143,24 +122,23 @@ func main() {
username := os.Args[2]
totpName := os.Args[3]
account, err := controller.GetAccountByUsername(app.DB, username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "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)
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
err = controller.DeleteTOTP(global.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return
@ -171,20 +149,20 @@ func main() {
}
username := os.Args[2]
account, err := controller.GetAccountByUsername(app.DB, username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "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)
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
totps, err := controller.GetTOTPsForAccount(global.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err)
os.Exit(1)
}
@ -204,25 +182,25 @@ func main() {
username := os.Args[2]
totpName := os.Args[3]
account, err := controller.GetAccountByUsername(app.DB, username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "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)
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
totp, err := controller.GetTOTP(global.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
os.Exit(1)
}
if totp == nil {
fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
os.Exit(1)
}
@ -230,103 +208,49 @@ func main() {
fmt.Printf("%s\n", code)
return
case "cleanTOTP":
err := controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
return
case "createInvite":
fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
fmt.Printf(
"Here you go! This code expires in %d hours: %s\n",
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
invite.Code,
)
fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code)
return
case "purgeInvites":
fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(app.DB)
err := controller.DeleteAllInvites(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
fmt.Printf("Invites deleted successfully.\n")
return
case "listAccounts":
accounts, err := controller.GetAllAccounts(app.DB)
accounts, err := controller.GetAllAccounts(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err)
os.Exit(1)
}
for _, account := range accounts {
email := "<none>"
if account.Email.Valid { email = account.Email.String }
fmt.Printf(
"User: %s\n" +
"\tID: %s\n" +
"\tEmail: %s\n" +
"\tCreated: %s\n" +
"\tLocked: %t\n",
"\tCreated: %s\n",
account.Username,
account.ID,
email,
account.Email,
account.CreatedAt,
account.Locked,
)
}
return
case "changePassword":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n")
os.Exit(1)
}
username := os.Args[2]
password := os.Args[3]
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)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
os.Exit(1)
}
account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, account)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username)
return
case "deleteAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
@ -335,14 +259,14 @@ func main() {
username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "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)
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
@ -352,95 +276,16 @@ func main() {
if !strings.HasPrefix(res, "y") {
return
}
err = controller.DeleteAccount(app.DB, account.ID)
err = controller.DeleteAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
case "lockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.LockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
return
case "unlockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.UnlockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
return
case "logs":
// TODO: add log search parameters
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
os.Exit(1)
}
for _, item := range(logs) {
levelStr := ""
switch item.Level {
case log.LEVEL_INFO:
levelStr = "INFO"
case log.LEVEL_WARN:
levelStr = "WARN"
default:
levelStr = fmt.Sprintf("? (%d)", item.Level)
}
fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
}
return
}
// command help
@ -450,41 +295,30 @@ func main() {
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
"cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\n" +
"\n" +
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" +
"listAccounts:\n\tLists all active accounts.\n",
"deleteAccount <username>:\n\tDeletes the account under `username`.\n",
"lockAccount <username>:\n\tLocks the account under `username`.\n",
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
"logs:\n\tShows system logs.\n",
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
)
return
}
// handle DB migrations
controller.CheckDBVersionAndMigrate(app.DB)
if app.Config.Twitch != nil {
err = controller.TwitchSetup(&app)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
}
}
controller.CheckDBVersionAndMigrate(global.DB)
// initial invite code
accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) }
if accountsCount == 0 {
_, err := app.DB.Exec("DELETE FROM invite")
_, err := global.DB.Exec("DELETE FROM invite")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
os.Exit(1)
}
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1)
@ -493,179 +327,66 @@ func main() {
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
}
// delete expired sessions
err = controller.DeleteExpiredSessions(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
os.Exit(1)
}
// delete expired invites
err = controller.DeleteExpiredInvites(app.DB)
err = controller.DeleteExpiredInvites(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
os.Exit(1)
}
// clean up unconfirmed TOTP methods
err = controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
os.Exit(1)
}
go cursor.StartCursor(&app)
// start the web server!
mux := createServeMux(&app)
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
stdLog.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
CheckRequest(&app, HTTPLog(DefaultHeaders(mux))),
mux := createServeMux()
fmt.Printf("Now serving at %s:%d\n", global.Config.BaseUrl, global.Config.Port)
log.Fatal(
http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port),
global.HTTPLog(global.DefaultHeaders(mux)),
))
}
func createServeMux(app *model.AppState) *http.ServeMux {
func createServeMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", view.IndexHandler(app))
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(global.DB)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(global.DB)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(global.DB)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads"))))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.Pages["index"].Execute(w, nil)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
staticHandler("public").ServeHTTP(w, r)
}))
return mux
}
var PoweredByStrings = []string{
"nerd rage",
"estrogen",
"your mother",
"awesome powers beyond comprehension",
"jared",
"the weight of my sins",
"the arc reactor",
"AA batteries",
"15 euro solar panel from ebay",
"magnets, how do they work",
"a fax machine",
"dell optiplex",
"a trans girl's nintendo wii",
"BASS",
"electricity, duh",
"seven hamsters in a big wheel",
"girls",
"mzungu hosting",
"golang",
"the state of the world right now",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
"the good folks at aperture science",
"free2play CDs",
"aridoodle",
"the love of creating",
"not for the sake of art; not for the sake of money; we like painting naked people",
"30 billion dollars in VC funding",
}
func CheckRequest(app *model.AppState, next http.Handler) http.Handler {
func staticHandler(directory string) 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
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
}
}
// 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") {
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
fmt.Fprintf(
os.Stderr,
"WARN: Suspicious activity blocked: {\"path\":\"%s\",\"address\":\"%s\"}\n",
r.URL.Path,
r.RemoteAddr,
)
return
}
next.ServeHTTP(w, r)
})
}
func DefaultHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "ari melody webbed site")
w.Header().Add("Do-Not-Stab", "1")
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
w.Header().Add("X-Hacker", "spare me please")
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
w.Header().Add("X-Thinking-With", "Portals")
w.Header().Add(
"X-Powered-By",
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
)
w.Header().Add(
"X-Days-Since-HRT",
fmt.Sprint(math.Round(time.Since(time.Unix(HRT_DATE, 0)).Hours() / 24)),
)
next.ServeHTTP(w, r)
})
}
type LoggingResponseWriter struct {
http.ResponseWriter
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) {
lrw.Status = status
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := LoggingResponseWriter{w, http.StatusOK}
next.ServeHTTP(&lrw, r)
after := time.Now()
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
elapsed := "<1"
if difference >= 1 {
elapsed = strconv.Itoa(difference)
}
statusColour := colour.Reset
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate),
r.Method,
r.URL.Path,
statusColour,
lrw.Status,
colour.Reset,
elapsed,
r.Header.Get("User-Agent"))
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
})
}

View file

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

View file

@ -1,50 +0,0 @@
package model
import (
"embed"
"github.com/jmoiron/sqlx"
"arimelody-web/log"
)
type (
DBConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
Name string `toml:"name"`
User string `toml:"user"`
Pass string `toml:"pass"`
}
DiscordConfig struct {
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
TwitchConfig struct {
Broadcaster string `toml:"broadcaster"`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
Config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Host string `toml:"host"`
Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"`
TrustedProxies []string `toml:"trusted_proxies"`
DB DBConfig `toml:"db"`
Discord *DiscordConfig `toml:"discord"`
Twitch *TwitchConfig `toml:"twitch"`
}
AppState struct {
DB *sqlx.DB
Config Config
Log log.Logger
Twitch *TwitchState
PublicFS embed.FS
}
)

View file

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

View file

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

View file

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

View file

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

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