Compare commits

...

37 commits

Author SHA1 Message Date
ari melody 0fc6c9f86d
update public gpg key 2025-02-19 01:59:12 +00:00
ari melody 3cb8d2940a
fix white-space on log message 2025-02-08 12:25:19 +00:00
ari melody edb4d7df3a
trim extra IPs from x-forwarded-for header 2025-02-08 12:16:03 +00:00
ari melody 23dbbf26e3
handle x-forwarded-for in IP logs 2025-02-08 12:15:50 +00:00
ari melody d9b71381b0
logs in use; new audit log panel! 2025-02-07 16:40:58 +00:00
ari melody 1397274967
log search implementation 2025-02-07 12:41:25 +00:00
ari melody 01c285de1b
merge 'dev' into feature/auditlogs 2025-02-07 12:08:35 +00:00
ari melody b7938b02e8
fix session handling on public API 2025-02-07 12:06:52 +00:00
ari melody aa144b719a
audit log basic db implementation 2025-02-06 13:45:33 +00:00
ari melody e80a6753a5
create log class, edit fatal-but-not-really logs 2025-02-06 12:32:51 +00:00
ari melody 8ccf6f242b
remove redundant console.log 2025-02-06 09:14:03 +00:00
ari melody e07c68a564
music gateway: minor css tweaks 2025-02-04 09:39:56 +00:00
ari melody e6c5ecc469
a11y: ARIA text for footer line
+ fixed indentation on some view sources
2025-02-04 09:29:44 +00:00
ari melody 4b36603b89
add missing command arg to readme 2025-01-27 19:57:46 +00:00
ari melody 64780611de
add license (MIT) 2025-01-27 18:30:50 +00:00
ari melody b7fce821b4
update prideflag.js 2025-01-27 18:30:27 +00:00
ari melody 70825ae875
fixed viewing invisible releases with admin session 2025-01-27 00:27:03 +00:00
ari melody 1efe52a8cb
fixed critical login TOTP bypass bug! whoops!!!!! 2025-01-27 00:04:08 +00:00
ari melody 2e93c3c5e5
fix typo in schema migrations...oops! 2025-01-26 20:51:37 +00:00
ari melody 2fc855450e
update bundler script, rename schema-migration directory 2025-01-26 20:47:48 +00:00
ari melody b91b6e7ce0
polished up TOTP enrolment 2025-01-26 20:37:20 +00:00
ari melody d2ac66a81a
merge feature/accountsettings into dev 2025-01-26 20:09:55 +00:00
ari melody 3450d879ac
QR codes complete, account settings finished!
+ refactored templates a little; this might need more work!
2025-01-26 20:09:18 +00:00
ari melody 1edc051ae2
fixed GetTOTP, started rough QR code implementation
GetTOTP handles TOTP method retrieval for confirmation and deletion.

QR code implementation looks like it's gonna suck, so might end up
using a library for this later.
2025-01-26 00:48:19 +00:00
ari melody ad39e68cd6
fix API endpoints which require account authorisation 2025-01-24 18:49:04 +00:00
ari melody 090de0554b
fix bug causing edit tracks component to crash 2025-01-24 01:33:14 +00:00
ari melody 9a27dbdc37
fixed style of inputs on release edit page (whoops!) 2025-01-24 01:18:46 +00:00
ari melody e004491b55
TOTP fully functioning, account settings done! 2025-01-23 13:53:06 +00:00
ari melody 50cbce92fc
TOTP methods can now be created on the frontend! 2025-01-23 12:09:46 +00:00
ari melody e457e979ff
tidying some things up
session message handling is pretty annoying; should look into a better method of doing this
2025-01-23 09:39:40 +00:00
ari melody 45f33b8b46
terrible no good massive refactor commit (oh yeah and built generic sessions for admin panel) 2025-01-23 00:37:19 +00:00
ari melody cee99a6932
merge main into feature/accountsettings 2025-01-22 11:40:44 +00:00
ari melody 2ee874b2ca
merge main into dev 2025-01-22 11:40:08 +00:00
ari melody c6afc274c2
light theme! crt now disabled by default 2025-01-22 11:39:15 +00:00
ari melody 0052c470f9
(incomplete) change password feature 2025-01-21 17:13:06 +00:00
ari melody 5531ef6bab
remove account API endpoints
account management should be done on the frontend.
some work will need to be done to generate API keys for external clients,
but notably some API endpoints are currently used by the frontend using session tokens.
2025-01-21 15:08:59 +00:00
ari melody 384579ee5e
refactored out global. long live AppState 2025-01-21 14:58:13 +00:00
87 changed files with 3119 additions and 1843 deletions

1
.gitignore vendored
View file

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

22
LICENSE.md Normal file
View file

@ -0,0 +1,22 @@
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

@ -42,6 +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.

View file

@ -1,43 +1,71 @@
package admin
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/log"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
type TemplateData struct {
Account *model.Account
Message string
Token string
func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/totp-setup", totpSetupHandler(app))
mux.Handle("/totp-confirm", totpConfirmHandler(app))
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
mux.Handle("/password", changePasswordHandler(app))
mux.Handle("/delete", deleteAccountHandler(app))
return mux
}
func AccountHandler(db *sqlx.DB) http.Handler {
func accountIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account := r.Context().Value("account").(*model.Account)
session := r.Context().Value("session").(*model.Session)
totps, err := controller.GetTOTPsForAccount(db, account.ID)
dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.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 AccountResponse struct {
Account *model.Account
TOTPs []model.TOTP
type (
TOTP struct {
model.TOTP
CreatedAtString string
}
accountResponse struct {
Session *model.Session
TOTPs []TOTP
}
)
totps := []TOTP{}
for _, totp := range dbTOTPs {
totps = append(totps, TOTP{
TOTP: totp,
CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"),
})
}
err = pages["account"].Execute(w, AccountResponse{
Account: account,
sessionMessage := session.Message
sessionError := session.Error
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
session.Message = sessionMessage
session.Error = sessionError
err = accountTemplate.Execute(w, accountResponse{
Session: session,
TOTPs: totps,
})
if err != nil {
@ -47,299 +75,296 @@ func AccountHandler(db *sqlx.DB) http.Handler {
})
}
func LoginHandler(db *sqlx.DB) http.Handler {
func changePasswordHandler(app *model.AppState) 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
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type LoginResponse struct {
Account *model.Account
Token string
Message string
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
}
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
}
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 {
render(LoginResponse{ Message: "Malformed request." })
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"),
if !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
account, err := controller.GetAccount(db, credentials.Username)
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)
if err != nil {
render(LoginResponse{ Message: "Invalid username or password" })
return
}
if account == nil {
render(LoginResponse{ Message: "Invalid username or password" })
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)
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != 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))
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 })
controller.SetSessionAccount(app.DB, session, nil)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Account deleted successfully.")
http.Redirect(w, r, "/admin/login", http.StatusFound)
})
}
func LogoutHandler(db *sqlx.DB) http.Handler {
type totpConfirmData struct {
Session *model.Session
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
}
func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
type totpSetupData struct {
Session *model.Session
}
session := r.Context().Value("session").(*model.Session)
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
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
}
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 := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
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)
}
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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
}
code := r.FormValue("totp")
if len(code) != controller.TOTP_CODE_LENGTH {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
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)
}
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
if code != confirmCode {
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if code != confirmCodeOffset {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = totpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
}
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.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
}
if len(r.URL.Path) < 2 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.URL.Path[1:]
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)
})
}
session := r.Context().Value("session").(*model.Session)
func createAccountHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkAccount, err := controller.GetAccountByRequest(db, r)
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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 checkAccount != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
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: 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
}
if r.Method != http.MethodPost {
if totp == nil {
http.NotFound(w, r)
return
}
err = r.ParseForm()
err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name)
if err != nil {
render(CreateAccountResponse{
Message: "Malformed data.",
})
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
}
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"),
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
// 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.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
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,16 +5,15 @@ import (
"net/http"
"strings"
"arimelody-web/global"
"arimelody-web/model"
"arimelody-web/controller"
)
func serveArtist() http.Handler {
func serveArtist(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0]
artist, err := controller.GetArtist(global.DB, id)
artist, err := controller.GetArtist(app.DB, id)
if err != nil {
if artist == nil {
http.NotFound(w, r)
@ -25,7 +24,7 @@ func serveArtist() http.Handler {
return
}
credits, err := controller.GetArtistCredits(global.DB, artist.ID, true)
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -33,15 +32,15 @@ func serveArtist() http.Handler {
}
type ArtistResponse struct {
Account *model.Account
Session *model.Session
Artist *model.Artist
Credits []*model.Credit
}
account := r.Context().Value("account").(*model.Account)
session := r.Context().Value("session").(*model.Session)
err = pages["artist"].Execute(w, ArtistResponse{
Account: account,
err = artistTemplate.Execute(w, ArtistResponse{
Session: session,
Artist: artist,
Credits: credits,
})

View file

@ -1,6 +1,6 @@
<dialog id="addcredit">
<header>
<h2>Add artist credit</h2>
<h2>Add Artist Credit</h2>
</header>
<ul>

View file

@ -1,6 +1,6 @@
<dialog id="addtrack">
<header>
<h2>Add track</h2>
<h2>Add Track</h2>
</header>
<ul>

View file

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

View file

@ -2,104 +2,444 @@ package admin
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
func Handler(db *sqlx.DB) http.Handler {
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
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("/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(http.StripPrefix("/account", accountHandler(app))))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/static/", http.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) {
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) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
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
}
session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(db, false, 0, true)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(db)
artists, err := controller.GetAllArtists(app.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
}
tracks, err := controller.GetOrphanTracks(db)
if err != nil {
tracks, err := controller.GetOrphanTracks(app.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
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
Account *model.Account
Session *model.Session
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
err = pages["index"].Execute(w, IndexData{
Account: account,
err = indexTemplate.Execute(w, IndexData{
Session: session,
Releases: releases,
Artists: artists,
Tracks: tracks,
})
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
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account, err := controller.GetAccountByRequest(db, r)
session := r.Context().Value("session").(*model.Session)
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
type registerData struct {
Session *model.Session
}
render := func() {
err := registerTemplate.Execute(w, registerData{ Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
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.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
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)
type loginData struct {
Session *model.Session
}
render := func() {
err := loginTemplate.Execute(w, loginData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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()
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid username or password.")
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
}
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
}
type loginTOTPData struct {
Session *model.Session
}
render := func() {
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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 (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid 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 = 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
}
ctx := context.WithValue(r.Context(), "account", account)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r)
})
}
@ -123,3 +463,36 @@ func staticHandler() http.Handler {
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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))
})
}

67
admin/logshttp.go Normal file
View file

@ -0,0 +1,67 @@
package admin
import (
"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 {
Session *model.Session
Logs []*log.Log
}
err = logsTemplate.Execute(w, LogsResponse{
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

@ -5,25 +5,24 @@ import (
"net/http"
"strings"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveRelease() http.Handler {
func serveRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0]
account := r.Context().Value("account").(*model.Account)
session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(global.DB, releaseID, true)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -34,10 +33,10 @@ func serveRelease() http.Handler {
serveEditCredits(release).ServeHTTP(w, r)
return
case "addcredit":
serveAddCredit(release).ServeHTTP(w, r)
serveAddCredit(app, release).ServeHTTP(w, r)
return
case "newcredit":
serveNewCredit().ServeHTTP(w, r)
serveNewCredit(app).ServeHTTP(w, r)
return
case "editlinks":
serveEditLinks(release).ServeHTTP(w, r)
@ -46,10 +45,10 @@ func serveRelease() http.Handler {
serveEditTracks(release).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(release).ServeHTTP(w, r)
serveAddTrack(app, release).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack().ServeHTTP(w, r)
serveNewTrack(app).ServeHTTP(w, r)
return
}
http.NotFound(w, r)
@ -57,12 +56,12 @@ func serveRelease() http.Handler {
}
type ReleaseResponse struct {
Account *model.Account
Session *model.Session
Release *model.Release
}
err = pages["release"].Execute(w, ReleaseResponse{
Account: account,
err = releaseTemplate.Execute(w, ReleaseResponse{
Session: session,
Release: release,
})
if err != nil {
@ -75,7 +74,7 @@ func serveRelease() http.Handler {
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 := components["editcredits"].Execute(w, release)
err := editCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -83,11 +82,11 @@ func serveEditCredits(release *model.Release) http.Handler {
})
}
func serveAddCredit(release *model.Release) http.Handler {
func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID)
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -98,7 +97,7 @@ func serveAddCredit(release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["addcredit"].Execute(w, response{
err = addCreditTemplate.Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
@ -109,12 +108,12 @@ func serveAddCredit(release *model.Release) http.Handler {
})
}
func serveNewCredit() http.Handler {
func serveNewCredit(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(global.DB, artistID)
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -124,7 +123,7 @@ func serveNewCredit() http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["newcredit"].Execute(w, artist)
err = newCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -135,7 +134,7 @@ func serveNewCredit() 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 := components["editlinks"].Execute(w, release)
err := editLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -146,7 +145,16 @@ 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")
err := components["edittracks"].Execute(w, release)
type editTracksData struct {
Release *model.Release
Add func(a int, b int) int
}
err := editTracksTemplate.Execute(w, editTracksData{
Release: release,
Add: func(a, b int) int { return a + b },
})
if err != nil {
fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -154,11 +162,11 @@ func serveEditTracks(release *model.Release) http.Handler {
})
}
func serveAddTrack(release *model.Release) http.Handler {
func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID)
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -169,7 +177,7 @@ func serveAddTrack(release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["addtrack"].Execute(w, response{
err = addTrackTemplate.Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
@ -181,10 +189,10 @@ func serveAddTrack(release *model.Release) http.Handler {
})
}
func serveNewTrack() http.Handler {
func serveNewTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(global.DB, trackID)
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -196,7 +204,7 @@ func serveNewTrack() http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = components["newtrack"].Execute(w, track)
err = newTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -24,7 +24,7 @@ nav {
justify-content: left;
background: #f8f8f8;
border-radius: .5em;
border-radius: 4px;
border: 1px solid #808080;
}
nav .icon {
@ -85,6 +85,15 @@ a img.icon {
height: .8em;
}
code {
background: #303030;
color: #f0f0f0;
padding: .23em .3em;
border-radius: 4px;
}
.card {
margin-bottom: 1em;
}
@ -93,13 +102,6 @@ a img.icon {
margin: 0 0 .5em 0;
}
/*
.card h3,
.card p {
margin: 0;
}
*/
.card-title {
margin-bottom: 1em;
display: flex;
@ -127,20 +129,34 @@ a img.icon {
#message,
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
margin: 0 0 1em 0;
padding: 1em;
border-radius: 4px;
background: #ffffff;
border: 1px solid #888;
}
#message {
background: #a9dfff;
border-color: #599fdc;
}
#error {
background: #ffa9b8;
border-color: #dc5959;
}
a.delete:not(.button) {
color: #d22828;
}
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border-radius: 4px;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
@ -154,35 +170,59 @@ button:active, .button:active {
border-color: #808080;
}
button {
.button, button {
color: inherit;
}
button.new {
.button.new, button.new {
background: #c4ff6a;
border-color: #84b141;
}
button.save {
.button.save, button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
.button.delete, button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
.button:hover, button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
.button:active, button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
.button[disabled], button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
form {
width: 100%;
display: block;
}
form label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
form input {
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}

View file

@ -1,28 +1,18 @@
@import url("/admin/static/index.css");
form#change-password {
width: 100%;
display: flex;
flex-direction: column;
align-items: start;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
div.card {
margin-bottom: 2rem;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
width: auto;
margin: 0;
display: flex;
align-items: center;
color: inherit;
}
input {
width: 100%;
width: min(20rem, calc(100% - 1rem));
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
@ -33,18 +23,11 @@ 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;
border-radius: 8px;
margin-bottom: .5em;
display: flex;
justify-content: space-between;

View file

@ -9,7 +9,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

View file

@ -11,7 +11,7 @@ input[type="text"] {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -160,7 +160,7 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -170,7 +170,7 @@ dialog div.dialog-actions {
}
.card.credits .credit .artist-avatar {
border-radius: .5em;
border-radius: 8px;
}
.card.credits .credit .artist-name {
@ -196,7 +196,7 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -215,7 +215,7 @@ dialog div.dialog-actions {
}
#editcredits .credit .artist-avatar {
border-radius: .5em;
border-radius: 8px;
}
#editcredits .credit .credit-info {
@ -228,12 +228,14 @@ dialog div.dialog-actions {
}
#editcredits .credit .credit-info .credit-attribute label {
width: auto;
margin: 0;
display: flex;
align-items: center;
}
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
margin-left: .25em;
margin: 0 0 0 .25em;
padding: .2em .4em;
flex-grow: 1;
font-family: inherit;
@ -241,6 +243,9 @@ dialog div.dialog-actions {
border-radius: 4px;
color: inherit;
}
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
}
#editcredits .credit .artist-name {
font-weight: bold;
@ -369,8 +374,10 @@ 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;
@ -393,7 +400,7 @@ dialog div.dialog-actions {
flex-direction: column;
gap: .5em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

View file

@ -11,7 +11,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

View file

@ -1,23 +1,5 @@
@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;
@ -26,7 +8,7 @@
align-items: center;
gap: .5em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -49,7 +31,7 @@
flex-direction: column;
gap: .5em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

86
admin/static/logs.css Normal file
View file

@ -0,0 +1,86 @@
main {
width: min(1080px, calc(100% - 2em))!important
}
form {
margin: 1em 0;
}
div#search {
display: flex;
}
#search input {
margin: 0;
flex-grow: 1;
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
#search button {
padding: 0 .5em;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
form #filters p {
margin: .5em 0 0 0;
}
form #filters label {
display: inline;
}
form #filters input {
margin-right: 1em;
display: inline;
}
#logs {
width: 100%;
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;
}
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;
}
.log:hover {
background: #fff8;
}
.log.warn {
background: #ffe86a;
}
.log.warn:hover {
background: #ffec81;
}

View file

@ -5,7 +5,7 @@
flex-direction: row;
gap: 1em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -50,7 +50,7 @@
padding: .5em;
display: block;
border-radius: .5em;
border-radius: 8px;
text-decoration: none;
color: #f0f0f0;
background: #303030;
@ -73,7 +73,7 @@
padding: .3em .5em;
display: inline-block;
border-radius: .3em;
border-radius: 4px;
background: #e0e0e0;
transition: color .1s, background .1s;

View file

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

View file

@ -5,16 +5,15 @@ import (
"net/http"
"strings"
"arimelody-web/global"
"arimelody-web/model"
"arimelody-web/controller"
)
func serveTrack() http.Handler {
func serveTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0]
track, err := controller.GetTrack(global.DB, id)
track, err := controller.GetTrack(app.DB, id)
if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -25,7 +24,7 @@ func serveTrack() http.Handler {
return
}
releases, err := controller.GetTrackReleases(global.DB, track.ID, true)
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil {
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -33,15 +32,15 @@ func serveTrack() http.Handler {
}
type TrackResponse struct {
Account *model.Account
Session *model.Session
Track *model.Track
Releases []*model.Release
}
account := r.Context().Value("account").(*model.Account)
session := r.Context().Value("session").(*model.Session)
err = pages["track"].Execute(w, TrackResponse{
Account: account,
err = trackTemplate.Execute(w, TrackResponse{
Session: session,
Track: track,
Releases: releases,
})

View file

@ -6,23 +6,27 @@
{{define "content"}}
<main>
<h1>Account Settings ({{.Account.Username}})</h1>
{{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-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">
<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">Password</label>
<input type="password" name="new-password" value="" autocomplete="new-password">
<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" name="confirm-password" value="" autocomplete="new-password">
</div>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
<button type="submit" class="save">Change Password</button>
</form>
@ -36,11 +40,11 @@
{{range .TOTPs}}
<div class="mfa-device">
<div>
<p class="mfa-device-name">{{.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAt}}</p>
<p class="mfa-device-name">{{.TOTP.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div>
<div>
<a class="delete">Delete</a>
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
</div>
</div>
{{end}}
@ -48,7 +52,10 @@
<p>You have no MFA devices.</p>
{{end}}
<button type="submit" class="new" id="add-mfa-device">Add MFA Device</button>
<div>
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
</div>
</div>
<div class="card-title">
@ -58,9 +65,17 @@
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
You will need to enter your password and TOTP below.
</p>
<button class="delete" id="delete">Delete Account</button>
<form action="/admin/account/delete" method="POST">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<button type="submit" class="delete">Delete Account</button>
</form>
</div>
</main>

View file

@ -36,13 +36,13 @@
{{if .Credits}}
{{range .Credits}}
<div class="credit">
<img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info">
<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>
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Artist.Role}}
{{if .Artist.Primary}}
Role: {{.Role}}
{{if .Primary}}
<small>(Primary)</small>
{{end}}
</p>

View file

@ -9,7 +9,7 @@
<div class="card-title">
<h1>Releases</h1>
<a class="create-btn" id="create-release">Create New</a>
<a class="button new" id="create-release">Create New</a>
</div>
<div class="card releases">
{{range .Releases}}
@ -22,7 +22,7 @@
<div class="card-title">
<h1>Artists</h1>
<a class="create-btn" id="create-artist">Create New</a>
<a class="button new" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
@ -38,7 +38,7 @@
<div class="card-title">
<h1>Tracks</h1>
<a class="create-btn" id="create-track">Create New</a>
<a class="button new" id="create-track">Create New</a>
</div>
<div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>

View file

@ -23,10 +23,20 @@
<div class="nav-item">
<a href="/admin">home</a>
</div>
<div class="flex-fill"></div>
{{if .Account}}
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a>
<a href="/admin/logs">logs</a>
</div>
{{end}}
<div class="flex-fill"></div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>
</div>
{{else}}
<div class="nav-item">

View file

@ -0,0 +1,47 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
form#login-totp {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
input {
width: calc(100% - 1rem - 2px);
}
</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

@ -3,15 +3,7 @@
<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 {
form#login {
width: 100%;
display: flex;
flex-direction: column;
@ -26,61 +18,33 @@ 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;
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{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}}
{{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">
<h1>Log In</h1>
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username">
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<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">
<input type="password" name="password" value="" autocomplete="current-password" required>
</div>
<button type="submit" class="save">Login</button>
</form>
{{end}}
</main>
{{end}}

View file

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

68
admin/views/logs.html Normal file
View file

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

View file

@ -11,7 +11,7 @@ a.discord {
color: #5865F2;
}
form {
form#register {
width: 100%;
display: flex;
flex-direction: column;
@ -26,45 +26,33 @@ 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;
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/register" method="POST" id="create-account">
<form action="/admin/register" method="POST" id="register">
<h1>Create Account</h1>
<div>
<label for="username">Username</label>
<input type="text" name="username" value="">
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="email">Email</label>
<input type="text" name="email" value="">
<input type="text" name="email" value="" autocomplete="email" required>
<label for="password">Password</label>
<input type="password" name="password" value="">
<input type="password" name="password" value="" autocomplete="new-password" required>
<label for="invite">Invite Code</label>
<input type="text" name="invite" value="">
<input type="text" name="invite" value="" autocomplete="off" required>
</div>
<button type="submit" class="new">Create Account</button>

View file

@ -0,0 +1,48 @@
{{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
.qr-code {
border: 1px solid #8888;
}
code {
user-select: all;
}
</style>
{{end}}
{{define "content"}}
<main>
{{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="" autocomplete="one-time-code" required autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "head"}}
<title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
{{end}}
{{define "content"}}
<main>
{{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,202 +0,0 @@
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,44 +1,33 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin"
"arimelody-web/controller"
"github.com/jmoiron/sqlx"
"arimelody-web/model"
)
func Handler(db *sqlx.DB) http.Handler {
func Handler(app *model.AppState) 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(db, artistID)
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -46,13 +35,13 @@ func Handler(db *sqlx.DB) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/artist/{id}
ServeArtist(artist).ServeHTTP(w, r)
ServeArtist(app, artist).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/artist/{id} (admin)
admin.RequireAccount(db, UpdateArtist(artist)).ServeHTTP(w, r)
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin)
admin.RequireAccount(db, DeleteArtist(artist)).ServeHTTP(w, r)
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -61,10 +50,10 @@ func Handler(db *sqlx.DB) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/artist
ServeAllArtists().ServeHTTP(w, r)
ServeAllArtists(app).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/artist (admin)
admin.RequireAccount(db, CreateArtist()).ServeHTTP(w, r)
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -74,13 +63,13 @@ func Handler(db *sqlx.DB) 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(db, releaseID, true)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -88,13 +77,13 @@ func Handler(db *sqlx.DB) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/music/{id}
ServeRelease(release).ServeHTTP(w, r)
ServeRelease(app, release).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/music/{id} (admin)
admin.RequireAccount(db, UpdateRelease(release)).ServeHTTP(w, r)
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin)
admin.RequireAccount(db, DeleteRelease(release)).ServeHTTP(w, r)
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -103,10 +92,10 @@ func Handler(db *sqlx.DB) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/music
ServeCatalog().ServeHTTP(w, r)
ServeCatalog(app).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/music (admin)
admin.RequireAccount(db, CreateRelease()).ServeHTTP(w, r)
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -116,13 +105,13 @@ func Handler(db *sqlx.DB) 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(db, trackID)
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -130,13 +119,13 @@ func Handler(db *sqlx.DB) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track/{id} (admin)
admin.RequireAccount(db, ServeTrack(track)).ServeHTTP(w, r)
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/track/{id} (admin)
admin.RequireAccount(db, UpdateTrack(track)).ServeHTTP(w, r)
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin)
admin.RequireAccount(db, DeleteTrack(track)).ServeHTTP(w, r)
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -145,14 +134,66 @@ func Handler(db *sqlx.DB) http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track (admin)
admin.RequireAccount(db, ServeAllTracks()).ServeHTTP(w, r)
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/track (admin)
admin.RequireAccount(db, CreateTrack()).ServeHTTP(w, r)
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}))
return mux
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, errors.New(fmt.Sprintf("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, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err))
}
if session != nil {
// TODO: consider running security checks here (i.e. user agent mismatches)
}
return session, nil
}

View file

@ -10,15 +10,15 @@ import (
"strings"
"time"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
func ServeAllArtists() http.Handler {
func ServeAllArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artists = []*model.Artist{}
artists, err := controller.GetAllArtists(global.DB)
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -35,7 +35,7 @@ func ServeAllArtists() http.Handler {
})
}
func ServeArtist(artist *model.Artist) http.Handler {
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type (
creditJSON struct {
@ -52,15 +52,10 @@ func ServeArtist(artist *model.Artist) http.Handler {
}
)
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
}
show_hidden_releases := account != nil
session := r.Context().Value("session").(*model.Session)
show_hidden_releases := session != nil && session.Account != nil
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
dbCredits, err := controller.GetArtistCredits(app.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)
@ -92,8 +87,10 @@ func ServeArtist(artist *model.Artist) http.Handler {
})
}
func CreateArtist() http.Handler {
func CreateArtist(app *model.AppState) 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 {
@ -107,7 +104,7 @@ func CreateArtist() http.Handler {
}
if artist.Name == "" { artist.Name = artist.ID }
err = controller.CreateArtist(global.DB, &artist)
err = controller.CreateArtist(app.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)
@ -118,12 +115,16 @@ func CreateArtist() 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(artist *model.Artist) http.Handler {
func UpdateArtist(app *model.AppState, 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)
@ -136,7 +137,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
} else {
if strings.Contains(artist.Avatar, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "avatar")
filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID)
filename, err := HandleImageUpload(app, &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 {
@ -155,7 +156,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
}
}
err = controller.UpdateArtist(global.DB, artist)
err = controller.UpdateArtist(app.DB, artist)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -164,12 +165,16 @@ func UpdateArtist(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(artist *model.Artist) http.Handler {
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := controller.DeleteArtist(global.DB, artist.ID)
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteArtist(app.DB, artist.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -178,5 +183,7 @@ func DeleteArtist(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

@ -10,23 +10,24 @@ import (
"strings"
"time"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
func ServeRelease(release *model.Release) http.Handler {
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases
privileged := false
if !release.Visible {
account, err := controller.GetAccountByRequest(global.DB, r)
session, err := controller.GetSessionFromRequest(app.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account != nil {
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
}
@ -67,14 +68,14 @@ func ServeRelease(release *model.Release) http.Handler {
if release.IsReleased() || privileged {
// get credits
credits, err := controller.GetReleaseCredits(global.DB, release.ID)
credits, err := controller.GetReleaseCredits(app.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(global.DB, credit.Artist.ID)
artist, err := controller.GetArtist(app.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)
@ -89,7 +90,7 @@ func ServeRelease(release *model.Release) http.Handler {
}
// get tracks
tracks, err := controller.GetReleaseTracks(global.DB, release.ID)
tracks, err := controller.GetReleaseTracks(app.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)
@ -104,7 +105,7 @@ func ServeRelease(release *model.Release) http.Handler {
}
// get links
links, err := controller.GetReleaseLinks(global.DB, release.ID)
links, err := controller.GetReleaseLinks(app.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)
@ -126,9 +127,9 @@ func ServeRelease(release *model.Release) http.Handler {
})
}
func ServeCatalog() http.Handler {
func ServeCatalog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -146,16 +147,11 @@ func ServeCatalog() http.Handler {
}
catalog := []Release{}
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
}
session := r.Context().Value("session").(*model.Session)
for _, release := range releases {
if !release.Visible {
privileged := false
if account != nil {
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
}
@ -192,12 +188,9 @@ func ServeCatalog() http.Handler {
})
}
func CreateRelease() http.Handler {
func CreateRelease(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)
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
@ -220,7 +213,7 @@ func CreateRelease() http.Handler {
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
err = controller.CreateRelease(global.DB, &release)
err = controller.CreateRelease(app.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)
@ -231,6 +224,8 @@ func CreateRelease() 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)
@ -243,8 +238,10 @@ func CreateRelease() http.Handler {
})
}
func UpdateRelease(release *model.Release) http.Handler {
func UpdateRelease(app *model.AppState, 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
@ -255,11 +252,11 @@ func UpdateRelease(release *model.Release) http.Handler {
if len(segments) == 2 {
switch segments[1] {
case "tracks":
UpdateReleaseTracks(release).ServeHTTP(w, r)
UpdateReleaseTracks(app, release).ServeHTTP(w, r)
case "credits":
UpdateReleaseCredits(release).ServeHTTP(w, r)
UpdateReleaseCredits(app, release).ServeHTTP(w, r)
case "links":
UpdateReleaseLinks(release).ServeHTTP(w, r)
UpdateReleaseLinks(app, release).ServeHTTP(w, r)
}
return
}
@ -281,7 +278,7 @@ func UpdateRelease(release *model.Release) http.Handler {
} else {
if strings.Contains(release.Artwork, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "musicart")
filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID)
filename, err := HandleImageUpload(app, &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 {
@ -300,7 +297,7 @@ func UpdateRelease(release *model.Release) http.Handler {
}
}
err = controller.UpdateRelease(global.DB, release)
err = controller.UpdateRelease(app.DB, release)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -309,11 +306,15 @@ func UpdateRelease(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(release *model.Release) http.Handler {
func UpdateReleaseTracks(app *model.AppState, 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 {
@ -321,7 +322,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
return
}
err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -330,11 +331,15 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
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(release *model.Release) http.Handler {
func UpdateReleaseCredits(app *model.AppState, 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
@ -358,7 +363,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
})
}
err = controller.UpdateReleaseCredits(global.DB, release.ID, credits)
err = controller.UpdateReleaseCredits(app.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)
@ -371,15 +376,14 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
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(release *model.Release) http.Handler {
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var links = []*model.Link{}
err := json.NewDecoder(r.Body).Decode(&links)
@ -388,7 +392,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
return
}
err = controller.UpdateReleaseLinks(global.DB, release.ID, links)
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -397,12 +401,16 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
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(release *model.Release) http.Handler {
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := controller.DeleteRelease(global.DB, release.ID)
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteRelease(app.DB, release.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -411,5 +419,7 @@ func DeleteRelease(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

@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
@ -17,7 +17,7 @@ type (
}
)
func ServeAllTracks() http.Handler {
func ServeAllTracks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type Track struct {
ID string `json:"id"`
@ -26,7 +26,7 @@ func ServeAllTracks() http.Handler {
var tracks = []Track{}
var dbTracks = []*model.Track{}
dbTracks, err := controller.GetAllTracks(global.DB)
dbTracks, err := controller.GetAllTracks(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -50,9 +50,9 @@ func ServeAllTracks() http.Handler {
})
}
func ServeTrack(track *model.Track) http.Handler {
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false)
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -74,12 +74,9 @@ func ServeTrack(track *model.Track) http.Handler {
})
}
func CreateTrack() http.Handler {
func CreateTrack(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)
var track model.Track
err := json.NewDecoder(r.Body).Decode(&track)
@ -93,26 +90,30 @@ func CreateTrack() http.Handler {
return
}
id, err := controller.CreateTrack(global.DB, &track)
id, err := controller.CreateTrack(app.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(track *model.Track) http.Handler {
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path == "/" {
if 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)
@ -124,13 +125,15 @@ func UpdateTrack(track *model.Track) http.Handler {
return
}
err = controller.UpdateTrack(global.DB, track)
err = controller.UpdateTrack(app.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")
@ -141,18 +144,22 @@ func UpdateTrack(track *model.Track) http.Handler {
})
}
func DeleteTrack(track *model.Track) http.Handler {
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path == "/" {
if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(global.DB, trackID)
err := controller.DeleteTrack(app.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,7 +1,8 @@
package api
import (
"arimelody-web/global"
"arimelody-web/log"
"arimelody-web/model"
"bufio"
"encoding/base64"
"errors"
@ -11,12 +12,12 @@ import (
"strings"
)
func HandleImageUpload(data *string, directory string, filename string) (string, error) {
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
split := strings.Split(*data, ";base64,")
header := split[0]
imageData, err := base64.StdEncoding.DecodeString(split[1])
ext, _ := strings.CutPrefix(header, "data:image/")
directory = filepath.Join(global.Config.DataDirectory, directory)
directory = filepath.Join(app.Config.DataDirectory, directory)
switch ext {
case "png":
@ -49,5 +50,7 @@ func HandleImageUpload(data *string, directory string, filename string) (string,
return "", nil
}
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
return filename, nil
}

View file

@ -6,4 +6,4 @@ if [ ! -f arimelody-web ]; then
exit 1
fi
tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/
tar czf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/

View file

@ -1,11 +1,7 @@
package controller
import (
"arimelody-web/global"
"arimelody-web/model"
"errors"
"fmt"
"net/http"
"strings"
"github.com/jmoiron/sqlx"
@ -22,7 +18,21 @@ func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
return accounts, nil
}
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
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) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
@ -50,12 +60,12 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
return &account, nil
}
func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
if token == "" { return nil, nil }
func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) {
if sessionToken == "" { return nil, nil }
account := model.Account{}
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token)
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
@ -66,42 +76,6 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
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,
@ -120,7 +94,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,
@ -132,7 +106,7 @@ func UpdateAccount(db *sqlx.DB, account *model.Account) error {
return err
}
func DeleteAccount(db *sqlx.DB, username string) error {
_, err := db.Exec("DELETE FROM account WHERE username=$1", username)
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}

View file

@ -2,6 +2,7 @@ package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)

View file

@ -1,4 +1,4 @@
package global
package controller
import (
"errors"
@ -6,44 +6,23 @@ import (
"os"
"strconv"
"github.com/jmoiron/sqlx"
"arimelody-web/model"
"github.com/pelletier/go-toml/v2"
)
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 {
func GetConfig() model.Config {
configFile := os.Getenv("ARIMELODY_CONFIG")
if configFile == "" {
configFile = "config.toml"
}
config := config{
config := model.Config{
BaseUrl: "https://arimelody.me",
Host: "0.0.0.0",
Port: 8080,
DB: dbConfig{
TrustedProxies: []string{ "127.0.0.1" },
DB: model.DBConfig{
Host: "127.0.0.1",
Port: 5432,
User: "arimelody",
@ -63,23 +42,22 @@ var Config = func() config {
err = toml.Unmarshal([]byte(data), &config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
os.Exit(1)
panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err))
}
err = handleConfigOverrides(&config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
os.Exit(1)
panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err))
}
return config
}()
}
func handleConfigOverrides(config *config) error {
func handleConfigOverrides(config *model.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()) }
@ -101,5 +79,3 @@ func handleConfigOverrides(config *config) error {
return nil
}
var DB *sqlx.DB

23
controller/ip.go Normal file
View file

@ -0,0 +1,23 @@
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

@ -8,7 +8,7 @@ import (
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 2
const DB_VERSION int = 3
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@ -41,6 +41,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "001-pre-versioning")
oldDBVersion = 2
case 2:
ApplyMigration(db, "002-audit-logs")
oldDBVersion = 3
}
}
@ -50,7 +54,7 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
func ApplyMigration(db *sqlx.DB, scriptFile string) {
fmt.Printf("Applying schema migration %s...\n", scriptFile)
bytes, err := os.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)

120
controller/qr.go Normal file
View file

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

View file

@ -5,6 +5,7 @@ import (
"fmt"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)

177
controller/session.go Normal file
View file

@ -0,0 +1,177 @@
package controller
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"time"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
}
var session *model.Session
if sessionCookie != nil {
// fetch existing session
session, err = GetSession(db, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
}
if session != nil {
// TODO: consider running security checks here (i.e. user agent mismatches)
}
}
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
}

View file

@ -1,61 +0,0 @@
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

@ -18,8 +18,8 @@ import (
)
const TOTP_SECRET_LENGTH = 32
const TIME_STEP int64 = 30
const CODE_LENGTH = 6
const TOTP_TIME_STEP int64 = 30
const TOTP_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() / TIME_STEP - int64(timeStepOffset)
counter := time.Now().Unix() / TOTP_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(CODE_LENGTH))
code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code)
return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code)
}
func GenerateTOTPSecret(length int) string {
@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string {
query := url.Query()
query.Set("secret", secret)
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))
// query.Set("algorithm", "SHA1")
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
// query.Set("period", fmt.Sprintf("%d", TOTP_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 " +
"WHERE account=$1 AND confirmed=true " +
"ORDER BY created_at ASC",
accountID,
)
@ -89,14 +89,36 @@ 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",
"WHERE account=$1 AND name=$2",
accountID,
name,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
@ -108,6 +130,15 @@ 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) " +
@ -127,3 +158,8 @@ 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

@ -2,6 +2,7 @@ package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)

View file

@ -1,38 +1,17 @@
package discord
import (
"arimelody-web/model"
"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"`
@ -68,15 +47,15 @@ type (
}
)
func GetOAuthTokenFromCode(code string) (string, error) {
func GetOAuthTokenFromCode(app *model.AppState, 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": {CLIENT_ID},
"client_secret": {CLIENT_SECRET},
"client_id": {app.Config.Discord.ClientID},
"client_secret": {app.Config.Discord.Secret},
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {OAUTH_CALLBACK_URI},
"redirect_uri": {GetOAuthCallbackURI(app.Config.BaseUrl)},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
@ -115,3 +94,15 @@ 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,3 +0,0 @@
package global
const COOKIE_TOKEN string = "AM_TOKEN"

View file

@ -1,101 +0,0 @@
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])
})
}

6
go.mod
View file

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

6
go.sum
View file

@ -8,7 +8,9 @@ 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=
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=
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=

142
log/log.go Normal file
View file

@ -0,0 +1,142 @@
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 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
}

326
main.go
View file

@ -3,23 +3,28 @@ package main
import (
"errors"
"fmt"
"log"
stdLog "log"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/colour"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model"
"arimelody-web/templates"
"arimelody-web/log"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// used for database migrations
@ -30,48 +35,50 @@ const DEFAULT_PORT int64 = 8080
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
// 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!
app := model.AppState{
Config: controller.GetConfig(),
}
// initialise database connection
if global.Config.DB.Host == "" {
if app.Config.DB.Host == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
os.Exit(1)
}
if global.Config.DB.Name == "" {
if app.Config.DB.Name == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
os.Exit(1)
}
if global.Config.DB.User == "" {
if app.Config.DB.User == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
os.Exit(1)
}
if global.Config.DB.Pass == "" {
if app.Config.DB.Pass == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
os.Exit(1)
}
var err error
global.DB, err = sqlx.Connect(
app.DB, err = sqlx.Connect(
"postgres",
fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
global.Config.DB.Host,
global.Config.DB.Port,
global.Config.DB.User,
global.Config.DB.Name,
global.Config.DB.Pass,
app.Config.DB.Host,
app.Config.DB.Port,
app.Config.DB.User,
app.Config.DB.Name,
app.Config.DB.Pass,
),
)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
os.Exit(1)
}
global.DB.SetConnMaxLifetime(time.Minute * 3)
global.DB.SetMaxOpenConns(10)
global.DB.SetMaxIdleConns(10)
defer global.DB.Close()
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 }
// handle command arguments
if len(os.Args) > 1 {
@ -85,31 +92,36 @@ func main() {
}
username := os.Args[2]
totpName := os.Args[3]
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
account, err := controller.GetAccount(global.DB, username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: 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(global.DB, &totp)
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
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)
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
@ -122,23 +134,24 @@ func main() {
username := os.Args[2]
totpName := os.Args[3]
account, err := controller.GetAccount(global.DB, username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.DeleteTOTP(global.DB, account.ID, totpName)
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: 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
@ -149,20 +162,20 @@ func main() {
}
username := os.Args[2]
account, err := controller.GetAccount(global.DB, username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totps, err := controller.GetTOTPsForAccount(global.DB, account.ID)
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
os.Exit(1)
}
@ -182,62 +195,80 @@ func main() {
username := os.Args[2]
totpName := os.Args[3]
account, err := controller.GetAccount(global.DB, username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totp, err := controller.GetTOTP(global.DB, account.ID, totpName)
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
os.Exit(1)
}
if totp == nil {
fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
os.Exit(1)
}
code := controller.GenerateTOTP(totp.Secret, 0)
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(global.DB, 16, time.Hour * 24)
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1)
}
fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code)
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,
)
return
case "purgeInvites":
fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(global.DB)
err := controller.DeleteAllInvites(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: 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(global.DB)
accounts, err := controller.GetAllAccounts(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: 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" +
@ -245,12 +276,46 @@ func main() {
"\tCreated: %s\n",
account.Username,
account.ID,
account.Email,
email,
account.CreatedAt,
)
}
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")
@ -259,14 +324,14 @@ func main() {
username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := controller.GetAccount(global.DB, username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
@ -277,15 +342,36 @@ func main() {
return
}
err = controller.DeleteAccount(global.DB, username)
err = controller.DeleteAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: 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 "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
@ -295,6 +381,7 @@ 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" +
@ -305,20 +392,20 @@ func main() {
}
// handle DB migrations
controller.CheckDBVersionAndMigrate(global.DB)
controller.CheckDBVersionAndMigrate(app.DB)
// initial invite code
accountsCount := 0
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) }
if accountsCount == 0 {
_, err := global.DB.Exec("DELETE FROM invite")
_, err := app.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(global.DB, 16, time.Hour * 24)
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1)
@ -328,28 +415,35 @@ func main() {
}
// delete expired invites
err = controller.DeleteExpiredInvites(global.DB)
err = controller.DeleteExpiredInvites(app.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)
}
// start the web server!
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)),
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),
HTTPLog(DefaultHeaders(mux)),
))
}
func createServeMux() *http.ServeMux {
func createServeMux(app *model.AppState) *http.ServeMux {
mux := http.NewServeMux()
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("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
@ -357,7 +451,7 @@ func createServeMux() *http.ServeMux {
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.Pages["index"].Execute(w, nil)
err := templates.IndexTemplate.Execute(w, nil)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -390,3 +484,93 @@ func staticHandler(directory string) http.Handler {
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
})
}
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])
})
}

View file

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

39
model/appstate.go Normal file
View file

@ -0,0 +1,39 @@
package model
import (
"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"`
}
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"`
}
AppState struct {
DB *sqlx.DB
Config Config
Log log.Logger
}
)

View file

@ -24,6 +24,7 @@ type (
Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"`
Links []*Link `json:"links"`
CreatedAt time.Time `json:"-" db:"created_at"`
}
)

18
model/session.go Normal file
View file

@ -0,0 +1,18 @@
package model
import (
"database/sql"
"time"
)
type Session struct {
Token string `json:"-" db:"token"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt time.Time `json:"-" db:"expires_at"`
Account *Account `json:"-" db:"-"`
AttemptAccount *Account `json:"-" db:"-"`
Message sql.NullString `json:"-" db:"message"`
Error sql.NullString `json:"-" db:"error"`
}

View file

@ -1,11 +0,0 @@
package model
import "time"
type Token struct {
Token string `json:"token" db:"token"`
AccountID string `json:"-" db:"account"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
}

View file

@ -9,4 +9,5 @@ type TOTP struct {
AccountID string `json:"accountID" db:"account"`
Secret string `json:"-" db:"secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Confirmed bool `json:"-" db:"confirmed"`
}

View file

@ -12,6 +12,8 @@ type (
Description string `json:"description"`
Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"`
Number int
}
)

View file

@ -1,13 +1,26 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJMEExYKADsWIQTu
jeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbAwULCQgHAgIiAgYVCgkICwIEFgID
AQIeBwIXgAAKCRDPmYKckmeBiGCbAP4wTcLCU5ZlfSTJrFtGhQKWA6DxtUO7Cegk
Vu8SgkY3KgEA1/YqjZ1vSaqPDN4137vmhkhfduoYOjN0iptNj39u2wG4OARk1bTd
EgorBgEEAZdVAQUBAQdAnA2drPzQBoXNdwIrFnovuF0CjX+8+8QSugCF4a5ZEXED
AQgHiHgEGBYKACAWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbDAAKCRDP
mYKckmeBiC/xAQD1hu4WcstR40lkUxMqhZ44wmizrDA+eGCdh7Ge3Gy79wEAx385
GnYoNplMTA4BTGs7orV4WSfSkoBx0+px1UOewgs=
=M1Bp
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF
CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB
iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ
+oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI
kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC
BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF
ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P
f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL
d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq
VgUJBaOeTACBdiAEGRYKAB0WIQQlu5dWmBR/P3ZxngxgtfA4bj3bfgUCZ7UmigAK
CRBgtfA4bj3bfux+AP4y5ydrjnGBMX7GuB2nh55SRdscSiXsZ66ntnjXyQcbWgEA
pDuu7FqXzXcnluuZxNFDT740Rnzs60tTeplDqGGWcAQJEM+ZgpySZ4GIc0kA/iSw
Nw+r3FC75omwrPpJF13B5fq93FweFx+oSaES6qzkAQDvgCK77qKKbvCju0g8zSsK
EZnv6xR4uvtGdVkvLpBdC7gzBGe1JpkWCSsGAQQB2kcPAQEHQGnU4lXFLchhKYkC
PshP+jvuRsNoedaDOK2p4dkQC8JuiH4EGBYKACYCGyAWIQTujeuNYocuegkeKt/P
mYKckmeBiAUCZ7UqXgUJBaOeRQAKCRDPmYKckmeBiL9KAQCJZIBhuSsoYa61I0XZ
cKzGZbB0h9pD6eg1VRswNIgHtQEAwu9Hgs1rs9cySvKbO7WgK6Qh6EfrvGgGOXCO
m3wVsg24OARntSo5EgorBgEEAZdVAQUBAQdA+/k586W1OHxndzDJNpbd+wqjyjr0
D5IXxfDs00advB0DAQgHiH4EGBYKACYWIQTujeuNYocuegkeKt/PmYKckmeBiAUC
Z7UqOQIbDAUJBaOagAAKCRDPmYKckmeBiEFxAQCgziQt2l3u7jnZVij4zop+K2Lv
TVFtkbG61tf6brRzBgD/X6c6X5BRyQC51JV1I1RFRBdeMAIXzcLFg2v3WUMccQs=
=YmHI
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,42 +1,33 @@
function toggle_config_setting(config, name) {
if (config[name]) {
delete config[name];
update_config(config);
return true;
}
config[name] = true;
update_config(config);
return true;
}
const DEFAULT_CONFIG = {
crt: false
};
const config = (() => {
let saved = localStorage.getItem("config");
if (saved) {
const config = JSON.parse(saved);
setCRT(config.crt || DEFAULT_CONFIG.crt);
return config;
}
function set_config_setting(config, name, value) {
config[name] = value;
update_config(config);
return true;
}
localStorage.setItem("config", JSON.stringify(DEFAULT_CONFIG));
return DEFAULT_CONFIG;
})();
function clear_config_setting(config, name) {
if (!config[name]) return false;
delete config[name];
update_config(config);
return true;
}
function update_config(config) {
localStorage.setItem("config", JSON.stringify(config));
}
const config = JSON.parse(localStorage.getItem("config")) || {};
if (config) {
if (config.disable_crt) {
document.querySelector('div#overlay').setAttribute("hidden", true);
document.body.style.textShadow = "none";
document.getElementById('toggle-crt').classList.add("disabled");
}
function saveConfig() {
localStorage.setItem("config", JSON.stringify(config));
}
document.getElementById("toggle-crt").addEventListener("click", () => {
toggle_config_setting(config, "disable_crt");
document.querySelector('div#overlay').toggleAttribute("hidden");
document.getElementById('toggle-crt').className = config.disable_crt ? "disabled" : "";
config.crt = !config.crt;
setCRT(config.crt);
saveConfig();
});
function setCRT(/** @type boolean */ enabled) {
if (enabled) {
document.body.classList.add("crt");
} else {
document.body.classList.remove("crt");
}
document.getElementById('toggle-crt').className = enabled ? "" : "disabled";
}

16
public/script/index.js Normal file
View file

@ -0,0 +1,16 @@
const hexPrimary = document.getElementById("hex-primary");
const hexSecondary = document.getElementById("hex-secondary");
const hexTertiary = document.getElementById("hex-tertiary");
function updateHexColours() {
const style = getComputedStyle(document.body);
hexPrimary.textContent = style.getPropertyValue('--primary');
hexSecondary.textContent = style.getPropertyValue('--secondary');
hexTertiary.textContent = style.getPropertyValue('--tertiary');
}
updateHexColours();
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
updateHexColours();
});

View file

@ -49,7 +49,6 @@ document.addEventListener("DOMContentLoaded", () => {
.filter((e) => e.innerText != "")
.forEach((e) => {
type_out(e);
console.log(e);
});
[...document.querySelectorAll("ol, ul")]
.filter((e) => e.innerText != "")

View file

@ -1,66 +1,74 @@
/**
* 🏳🌈🏳💖 pride flag 💖🏳🏳🌈
* made with by ari melody, 2023
*
* web: https://arimelody.me
* source: https://github.com/mellodoot/prideflag
*/
//
// pride flag - copyright (c) 2024 ari melody
//
// this code is provided AS-IS, WITHOUT ANY WARRANTY, to be
// freely redistributed and/or modified as you please, however
// retaining this license in any redistribution.
//
// please use this flag to link to an LGBTQI+-supporting page
// of your choosing!
//
// web: https://arimelody.me
// source: https://git.arimelody.me/ari/prideflag
//
const pride_url = "https://git.arimelody.me/ari/prideflag";
const pride_flag_svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>`;
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>`;
const pride_flag_css =
`#pride-flag svg {
position: fixed;
top: 0;
right: 0;
width: 120px;
transform-origin: 100% 0%;
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
z-index: 8008135;
pointer-events: none;
}
#pride-flag svg:hover {
transform: scale(110%);
}
#pride-flag svg:active {
transform: scale(110%);
}
#pride-flag svg * {
pointer-events: all;
}`;
`#prideflag {
position: fixed;
top: 0;
right: 0;
width: 120px;
transform-origin: 100% 0%;
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
z-index: 8008135;
pointer-events: none;
}
#prideflag:hover {
transform: scale(110%);
}
#prideflag:active {
transform: scale(110%);
}
#prideflag * {
pointer-events: all;
}`;
function create_pride_flag() {
const container = document.createElement("a");
container.id = "pride-flag";
container.href = "https://github.com/mellodoot/prideflag";
container.target = "_blank";
container.innerHTML = pride_flag_svg;
return container;
const flag = document.createElement("a");
flag.id = "prideflag";
flag.href = pride_url;
flag.target = "_blank";
flag.innerHTML = pride_flag_svg;
return flag;
}
function load_pride_flag_style() {
const pride_stylesheet = document.createElement('style');
pride_stylesheet.textContent = pride_flag_css;
document.head.appendChild(pride_stylesheet);
const pride_stylesheet = document.createElement('style');
pride_stylesheet.textContent = pride_flag_css;
document.head.appendChild(pride_stylesheet);
}
load_pride_flag_style();
pride_flag = create_pride_flag();
document.querySelector("main").appendChild(pride_flag);
flag = create_pride_flag();
document.body.appendChild(flag);

View file

@ -1,19 +1,34 @@
:root {
--primary: #b7fd49;
--secondary: #f8e05b;
--tertiary: #f788fe;
--links: #5eb2ff;
--background: #080808;
--on-background: #f0f0f0;
--primary: #b7fd49;
--secondary: #f8e05b;
--tertiary: #f788fe;
--links: #5eb2ff;
}
@media (prefers-color-scheme: light) {
:root {
--background: #ffffff;
--on-background: #101010;
--primary: #6d9e23;
--secondary: #a5911e;
--tertiary: #a92cb1;
--links: #3ba1ff;
}
}
.col-primary {
color: var(--primary);
color: var(--primary);
}
.col-secondary {
color: var(--secondary);
color: var(--secondary);
}
.col-tertiary {
color: var(--tertiary);
color: var(--tertiary);
}

View file

@ -1,11 +1,11 @@
footer {
border-top: 1px solid #888;
border-top: 1px solid #8888;
}
#footer {
width: min(calc(100% - 4rem), 720px);
margin: auto;
padding: 2rem 0;
color: #aaa;
width: min(calc(100% - 4rem), 720px);
margin: auto;
padding: 2rem 0;
color: #aaa;
}

View file

@ -1,187 +1,189 @@
header {
position: fixed;
top: 0;
left: 0;
width: 100vw;
border-bottom: 1px solid #888;
background-color: #080808;
z-index: 1;
position: fixed;
top: 0;
left: 0;
width: 100vw;
border-bottom: 1px solid #8888;
background-color: var(--background);
z-index: 1;
transition: color .2s, background-color .2s;
}
nav {
width: min(calc(100% - 4rem), 720px);
height: 3em;
margin: auto;
padding: 0 1em;
display: flex;
flex-direction: row;
gap: .8em;
align-items: center;
width: min(calc(100% - 4rem), 720px);
height: 3em;
margin: auto;
padding: 0 1em;
display: flex;
flex-direction: row;
gap: .8em;
align-items: center;
}
#header-home {
flex-grow: 1;
display: flex;
gap: .5em;
cursor: pointer;
flex-grow: 1;
display: flex;
gap: .5em;
cursor: pointer;
}
img#header-icon {
width: 2em;
height: 2em;
margin: .5em;
display: block;
width: 2em;
height: 2em;
margin: .5em;
display: block;
}
#header-text {
width: 11em;
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
width: 11em;
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
}
#header-text h1 {
margin: 0;
font-size: 1em;
margin: 0;
font-size: 1em;
}
#header-text h2 {
height: 1.2em;
line-height: 1.2em;
margin: 0;
font-size: .7em;
color: #bbb;
height: 1.2em;
line-height: 1.2em;
margin: 0;
font-size: .7em;
color: #bbb;
}
#header-links-toggle {
width: 3em;
height: 3em;
display: none;
justify-content: center;
align-items: center;
transition: background-color .2s;
width: 3em;
height: 3em;
display: none;
justify-content: center;
align-items: center;
transition: background-color .2s;
}
#header-links-toggle:hover {
background-color: #fff2;
background-color: #fff2;
}
header ul#header-links {
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
gap: .5em;
align-items: center;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
gap: .5em;
align-items: center;
}
header ul li {
list-style: none;
list-style: none;
}
header ul li a,
header ul li span {
padding: .4em .5em;
border: 1px solid var(--links);
color: var(--links);
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
text-decoration: none;
padding: .4em .5em;
border: 1px solid var(--links);
color: var(--links);
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
text-decoration: none;
}
header ul li span {
color: #aaa;
border-color: #aaa;
cursor: default;
text-decoration: none;
color: #aaa;
border-color: #aaa;
cursor: default;
text-decoration: none;
}
header ul li a:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
}
#toggle-crt a {
color: var(--primary);
border-color: var(--primary);
opacity: 1;
color: var(--primary);
border-color: var(--primary);
opacity: 1;
}
#toggle-crt a:hover {
color: #111;
background-color: var(--primary) !important;
color: #111;
background-color: var(--primary) !important;
}
#toggle-crt.disabled a {
opacity: .5 !important;
opacity: .5 !important;
}
@media screen and (max-width: 780px) {
header {
font-size: 14px;
}
header {
font-size: 14px;
}
nav {
width: calc(100vw - 2rem);
margin: 0;
}
nav {
width: calc(100vw - 2rem);
margin: 0;
}
div#header-text {
flex-grow: 1;
}
div#header-text {
flex-grow: 1;
}
a#header-links-toggle {
display: flex;
}
a#header-links-toggle {
display: flex;
}
header ul#header-links {
position: fixed;
left: 0;
top: 2.7rem;
width: calc(100vw - 2rem);
padding: 1rem;
flex-direction: column;
gap: 1rem;
border-bottom: 1px solid #888;
background: #080808;
display: none;
}
header ul#header-links {
position: fixed;
left: 0;
top: 2.7rem;
width: calc(100vw - 2rem);
padding: 1rem;
flex-direction: column;
gap: 1rem;
border-bottom: 1px solid #888;
background: #080808;
display: none;
}
header ul#header-links.open {
display: flex;
}
header ul#header-links.open {
display: flex;
}
ul#header-links li {
width: 100%;
}
ul#header-links li {
width: 100%;
}
ul#header-links li a,
ul#header-links li span {
margin: 0;
display: block;
font-size: 1em;
text-align: center;
}
ul#header-links li a,
ul#header-links li span {
margin: 0;
display: block;
font-size: 1em;
text-align: center;
}
}
@keyframes list-item-fadein {
from {
opacity: 1;
background: #fff8;
}
from {
opacity: 1;
background: #fff8;
}
to {
opacity: 1;
background: transparent;
}
to {
opacity: 1;
background: transparent;
}
}

View file

@ -1,43 +1,43 @@
main {
width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem);
margin: 0 auto 2rem auto;
padding-top: 4rem;
width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem);
margin: 0 auto 2rem auto;
padding-top: 4rem;
}
main h1 {
line-height: 3rem;
color: var(--primary);
line-height: 3rem;
color: var(--primary);
}
main h2 {
color: var(--secondary);
color: var(--secondary);
}
main h3 {
color: var(--tertiary);
color: var(--tertiary);
}
div#me_irl {
width: fit-content;
height: fit-content;
border: 2px solid white;
width: fit-content;
height: fit-content;
border: 2px solid white;
}
div#me_irl img {
display: block;
display: block;
}
div#me_irl::before {
content: "";
position: absolute;
width: 104px;
height: 104px;
transform: translate(2px, 2px);
background-image: linear-gradient(to top right,
var(--primary),
var(--secondary));
z-index: -1;
content: "";
position: absolute;
width: 104px;
height: 104px;
transform: translate(2px, 2px);
background-image: linear-gradient(to top right,
var(--primary),
var(--secondary));
z-index: -1;
}
h1,
@ -49,7 +49,7 @@ h6,
p,
small,
blockquote {
transition: background-color 0.1s;
transition: background-color 0.1s;
}
h1 a,
@ -58,7 +58,7 @@ h3 a,
h4 a,
h5 a,
h6 a {
color: inherit;
color: inherit;
}
h1 a:hover,
@ -67,7 +67,7 @@ h3 a:hover,
h4 a:hover,
h5 a:hover,
h6 a:hover {
text-decoration: none;
text-decoration: none;
}
main h1:hover,
@ -79,72 +79,72 @@ main h6:hover,
main p:hover,
main small:hover,
main blockquote:hover {
background-color: #fff1;
background-color: #fff1;
}
blockquote {
margin: 1rem 0;
padding: 0 2.5rem;
margin: 1rem 0;
padding: 0 2.5rem;
}
hr {
text-align: center;
line-height: 0px;
border-width: 1px 0 0 0;
border-color: #888f;
margin: 1.5em 0;
overflow: visible;
text-align: center;
line-height: 0px;
border-width: 1px 0 0 0;
border-color: #888;
margin: 1.5em 0;
overflow: visible;
}
ul.links {
display: flex;
gap: 1em .5em;
flex-wrap: wrap;
display: flex;
gap: 1em .5em;
flex-wrap: wrap;
}
ul.links li {
list-style: none;
list-style: none;
}
ul.links li a {
padding: .4em .5em;
border: 1px solid var(--links);
color: var(--links);
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
padding: .4em .5em;
border: 1px solid var(--links);
color: var(--links);
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
}
ul.links li a:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
box-shadow: 0 0 1em var(--links);
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
box-shadow: 0 0 1em var(--links);
}
div#web-buttons {
margin: 2rem 0;
margin: 2rem 0;
}
#web-buttons a {
text-decoration: none;
text-decoration: none;
}
#web-buttons img {
image-rendering: auto;
image-rendering: crisp-edges;
image-rendering: pixelated;
image-rendering: auto;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
#web-buttons img:hover {
margin: -1px;
border: 1px solid #eee;
transform: translate(-2px, -2px);
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
margin: -1px;
border: 1px solid #eee;
transform: translate(-2px, -2px);
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
}

View file

@ -14,15 +14,17 @@
body {
margin: 0;
padding: 0;
background: #080808;
color: #eee;
background: var(--background);
color: var(--on-background);
font-family: "Monaspace Argon", monospace;
font-size: 18px;
text-shadow: 0 0 3em;
scroll-behavior: smooth;
transition: color .2s, background-color .2s;
}
main {
body.crt #overlay {
display: block;
}
a {
@ -118,6 +120,7 @@ a#backtotop:hover {
left: 0;
width: 100vw;
height: 100vh;
display: none;
background-image: linear-gradient(180deg, rgba(0,0,0,0) 15%, rgb(0, 0, 0) 40%, rgb(0, 0, 0) 60%, rgba(0,0,0,0) 85%);
background-size: 100vw .2em;
background-repeat: repeat;
@ -136,3 +139,27 @@ a#backtotop:hover {
}
}
@media (prefers-color-scheme: light) {
a.link-button:hover {
box-shadow: none;
}
@keyframes list-item-fadein {
from {
opacity: 1;
background: var(--links);
}
to {
opacity: 1;
background: transparent;
}
}
}
@media (prefers-color-scheme: dark) {
body.crt {
text-shadow: 0 0 3em;
}
}

View file

@ -17,6 +17,10 @@ body {
font-family: "Monaspace Argon", monospace;
}
header {
background-color: #111;
}
#background {
position: fixed;
top: 0;
@ -258,7 +262,7 @@ div#info p {
#title,
#artist {
text-shadow: 0 .05em 2px #0004
text-shadow: 0 .05em 2px #0004;
}
#type {
@ -609,6 +613,10 @@ footer a:hover {
margin: 0 auto;
}
#tracks h2 {
margin: 0 auto .8em auto;
}
#lyrics p.album-track-subheading {
margin-bottom: 1em;
}

View file

@ -1,146 +1,129 @@
main {
width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem);
margin: 0 auto 2rem auto;
padding-top: 4rem;
width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem);
margin: 0 auto 2rem auto;
padding-top: 4rem;
}
main nav {
margin: -1rem .5rem 1rem .5rem;
margin: -1rem .5rem 1rem .5rem;
}
div.music {
margin-bottom: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: row;
gap: 1.5em;
border: 1px solid #222;
border-radius: 4px;
background-color: #ffffff08;
transition: background-color .1s;
text-decoration: none;
cursor: pointer;
margin-bottom: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: row;
gap: 1.5em;
border: 1px solid #8882;
border-radius: 4px;
background-color: #ffffff08;
transition: background-color .1s;
text-decoration: none;
cursor: pointer;
}
div.music:hover {
background-color: #fff1;
background-color: #fff1;
}
div.music a {
text-decoration: none;
text-decoration: none;
}
.music h1:hover,
.music h2:hover,
.music h3:hover {
background: initial;
background: initial;
}
.music-artwork img {
border: 1px solid #888;
border: 1px solid #8888;
}
.music-title {
margin: 0;
color: #eee;
font-size: 1.6em;
line-height: 1.6em;
margin: 0;
color: var(--on-background);
font-size: 1.6em;
line-height: 1.6em;
}
.music-title a {
color: inherit;
transition: color .2s;
}
.music-year {
color: #888;
color: #888;
}
.music-artist {
margin: -.5rem 0 0 0;
font-size: 1em;
color: #aaa;
margin: -.5rem 0 0 0;
font-size: 1em;
color: #aaa;
}
h3[class^=music-type] {
margin: 0 0 1rem 0;
font-size: .8em;
color: #eee;
margin: 0 0 1rem 0;
font-size: .8em;
color: #eee;
transition: color .2s;
}
h3.music-type-single {
color: var(--tertiary);
color: var(--tertiary);
}
h3.music-type-compilation {
color: var(--secondary);
color: var(--secondary);
}
h3.music-type-album {
color: var(--primary);
color: var(--primary);
}
h3.music-type-upcoming {
color: #f47070;
color: #f47070;
}
.music-links {
width: fit-content;
margin: .5em 0;
padding: 0;
display: flex;
gap: .5rem;
flex-wrap: wrap;
line-height: 1.7em;
justify-content: center;
width: fit-content;
margin: .5em 0;
padding: 0;
display: flex;
gap: .5rem;
flex-wrap: wrap;
line-height: 1.7em;
justify-content: center;
}
.music-links li {
list-style: none;
list-style: none;
}
/*
.music-links li a {
padding: .2em .5em;
border: 1px solid #65b4fd;
color: #65b4fd;
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-duration: .2s;
animation: list-item-fadein .2s forwards;
animation-delay: 0s;
opacity: 0;
}
.music-links li a:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
}
*/
h2.question {
margin: 1rem 0;
padding: 1rem 1.5rem;
border-radius: 4px;
cursor: pointer;
margin: 1rem 0;
padding: 1rem 1.5rem;
border-radius: 4px;
cursor: pointer;
}
div.answer {
margin: -1rem 0 1rem 0;
padding: .5em 1.5em;
border-radius: 4px;
margin: -1rem 0 1rem 0;
padding: .5em 1.5em;
border-radius: 4px;
}
@media screen and (max-width: 740px) {
div.music {
flex-direction: column;
}
div.music {
flex-direction: column;
}
.music-artwork,
.music-details {
text-align: center;
align-items: center;
display: flex;
flex-direction: column;
}
.music-artwork,
.music-details {
text-align: center;
align-items: center;
display: flex;
flex-direction: column;
}
}

View file

@ -1,51 +1,62 @@
CREATE SCHEMA IF NOT EXISTS arimelody;
--
-- Tables
--
-- 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
);
-- Accounts
CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
password text NOT NULL,
email text,
avatar_url text,
created_at TIMESTAMP DEFAULT current_timestamp
id UUID DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
account uuid NOT NULL,
privilege text NOT NULL
account UUID NOT NULL,
privilege TEXT NOT NULL
);
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
-- Invites
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Tokens
CREATE TABLE arimelody.token (
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
attempt_account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTPs
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
confirmed BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
@ -72,7 +83,8 @@ CREATE TABLE arimelody.musicrelease (
buyname text,
buylink text,
copyright text,
copyrightURL text
copyrightURL text,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
);
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
@ -118,7 +130,8 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
--
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,67 +1,58 @@
--
-- Migration
--
-- Move existing tables to new schema
ALTER TABLE public.artist SET SCHEMA arimelody;
ALTER TABLE public.musicrelease SET SCHEMA arimelody;
ALTER TABLE public.musiclink SET SCHEMA arimelody;
ALTER TABLE public.musiccredit SET SCHEMA arimelody;
ALTER TABLE public.musictrack SET SCHEMA arimelody;
ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody;
--
-- New items
--
-- Acounts
-- Accounts
CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
password text NOT NULL,
email text,
avatar_url text,
id UUID DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
created_at TIMESTAMP DEFAULT current_timestamp
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
account uuid NOT NULL,
privilege text NOT NULL
account UUID NOT NULL,
privilege TEXT NOT NULL
);
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
-- Invites
CREATE TABLE arimelody.invite (
code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Tokens
CREATE TABLE arimelody.token (
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
attempt_account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTPs
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
confirmed BOOLEAN DEFAULT false
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- Foreign keys
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -0,0 +1,12 @@
-- 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

@ -5,29 +5,24 @@ import (
"path/filepath"
)
var Pages = map[string]*template.Template{
"index": template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "index.html"),
)),
"music": template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music.html"),
)),
"music-gateway": template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music-gateway.html"),
)),
}
var Components = map[string]*template.Template{
}
var IndexTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "index.html"),
))
var MusicTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music.html"),
))
var MusicGatewayTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music-gateway.html"),
))

View file

@ -8,38 +8,36 @@ import (
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/templates"
"github.com/jmoiron/sqlx"
)
// HTTP HANDLER METHODS
func MusicHandler(db *sqlx.DB) http.Handler {
func MusicHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
ServeCatalog(db).ServeHTTP(w, r)
ServeCatalog(app).ServeHTTP(w, r)
return
}
release, err := controller.GetRelease(db, r.URL.Path[1:], true)
release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true)
if err != nil {
http.NotFound(w, r)
return
}
ServeGateway(db, release).ServeHTTP(w, r)
ServeGateway(app, release).ServeHTTP(w, r)
}))
return mux
}
func ServeCatalog(db *sqlx.DB) http.Handler {
func ServeCatalog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := controller.GetAllReleases(db, true, 0, true)
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
if err != nil {
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -50,25 +48,26 @@ func ServeCatalog(db *sqlx.DB) http.Handler {
}
}
err = templates.Pages["music"].Execute(w, releases)
err = templates.MusicTemplate.Execute(w, releases)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func ServeGateway(db *sqlx.DB, release *model.Release) http.Handler {
func ServeGateway(app *model.AppState, 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 {
account, err := controller.GetAccountByRequest(db, r)
session, err := controller.GetSessionFromRequest(app.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account != nil {
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
}
@ -87,7 +86,7 @@ func ServeGateway(db *sqlx.DB, release *model.Release) http.Handler {
response.Links = release.Links
}
err := templates.Pages["music-gateway"].Execute(w, response)
err := templates.MusicGatewayTemplate.Execute(w, response)
if err != nil {
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)

View file

@ -6,16 +6,16 @@
{{define "content"}}
<main>
<h1>
# 404 - not found!
</h1>
<h1>
# 404 - not found!
</h1>
<p>
the page you're looking for does not exist.
<br>
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
</p>
<p>
the page you're looking for does not exist.
<br>
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
</p>
<p><small>status: ERR_NOT_FOUND</small></p>
<p><small>status: ERR_NOT_FOUND</small></p>
</main>
{{end}}

View file

@ -1,9 +1,9 @@
{{define "footer"}}
<footer>
<div id="footer">
<small><em>*made with ♥ by ari, 2024*</em></small>
</div>
<div id="footer">
<small><em>*made with <span aria-label="love"></span> by ari, 2025*</em></small>
</div>
</footer>
{{end}}

View file

@ -1,44 +1,44 @@
{{define "header"}}
<header>
<nav>
<div id="header-home">
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
<div id="header-text">
<h1>ari melody</h1>
<h2>your local SPACEGIRL 💫</h2>
</div>
</div>
<a id="header-links-toggle">
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
</svg>
</a>
<ul id="header-links">
<li>
<a href="/" preload="mouseover">home</a>
</li>
<li>
<a href="/music" preload="mouseover">music</a>
</li>
<li>
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">blog</span>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">art</span>
</li>
<li id="toggle-crt">
<a href="javascript:void(0)">crt</a>
</li>
</ul>
</nav>
<nav>
<div id="header-home">
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
<div id="header-text">
<h1>ari melody</h1>
<h2>your local SPACEGIRL 💫</h2>
</div>
</div>
<a id="header-links-toggle">
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
</svg>
</a>
<ul id="header-links">
<li>
<a href="/" preload="mouseover">home</a>
</li>
<li>
<a href="/music" preload="mouseover">music</a>
</li>
<li>
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">blog</span>
</li>
<li>
<!-- coming later! -->
<span title="coming later!">art</span>
</li>
<li id="toggle-crt">
<a href="javascript:void(0)">crt</a>
</li>
</ul>
</nav>
</header>
{{end}}

View file

@ -16,6 +16,8 @@
<link rel="me" href="https://ice.arimelody.me/@ari">
<link rel="me" href="https://wetdry.world/@ari">
<script type="module" src="/script/index.js" defer> </script>
{{end}}
{{define "content"}}
@ -66,9 +68,9 @@
<strong>my colours 🌈</strong>
</p>
<ul>
<li>primary: <span class="col-primary">#b7fd49</span></li>
<li>secondary: <span class="col-secondary">#f8e05b</span></li>
<li>tertiary: <span class="col-tertiary">#f788fe</span></li>
<li>primary: <span class="col-primary" id="hex-primary">#b7fd49</span></li>
<li>secondary: <span class="col-secondary" id="hex-secondary">#f8e05b</span></li>
<li>tertiary: <span class="col-tertiary" id="hex-tertiary">#f788fe</span></li>
</ul>
<p>

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
@ -10,25 +10,25 @@
{{block "head" .}}{{end}}
<script type="module", src="/script/main.js"></script>
</head>
</head>
<body>
<body>
{{template "header"}}
{{block "content" .}}
<main>
<h1>
# hello, world!
</h1>
<p>
this is a default page!
</p>
<h1>
# hello, world!
</h1>
<p>
this is a default page!
</p>
</main>
{{end}}
{{template "footer"}}
<div id="overlay"></div>
{{template "prideflag"}}
</body>
</body>
</html>

View file

@ -1,21 +1,21 @@
{{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"/>
<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="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>
<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}}