Compare commits

..

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

115 changed files with 2253 additions and 5265 deletions

View file

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

2
.gitignore vendored
View file

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

View file

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

View file

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

View file

@ -42,11 +42,7 @@ need to be up for this, making this ideal for some offline maintenance.
- `listTOTP <username>`: Lists an account's TOTP methods. - `listTOTP <username>`: Lists an account's TOTP methods.
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method. - `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
- `testTOTP <username> <name>`: Generates the code for 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. - `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes. - `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts. - `listAccounts`: Lists all active accounts.
- `deleteAccount <username>`: Deletes an account with a given `username`. - `deleteAccount <username>`: Deletes an account with a given `username`.
- `lockAccount <username>`: Locks the account under `username`.
- `unlockAccount <username>`: Unlocks the account under `username`.
- `logs`: Shows system logs.

View file

@ -1,71 +1,43 @@
package admin package admin
import ( import (
"database/sql" "fmt"
"fmt" "net/http"
"net/http" "os"
"net/url" "strings"
"os" "time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"golang.org/x/crypto/bcrypt" "github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
) )
func accountHandler(app *model.AppState) http.Handler { type TemplateData struct {
mux := http.NewServeMux() Account *model.Account
Message string
mux.Handle("/totp-setup", totpSetupHandler(app)) Token string
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 accountIndexHandler(app *model.AppState) http.Handler { func AccountHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session) account := r.Context().Value("account").(*model.Account)
dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) totps, err := controller.GetTOTPsForAccount(db, account.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err) fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
type ( type AccountResponse struct {
TOTP struct { Account *model.Account
model.TOTP TOTPs []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"),
})
} }
sessionMessage := session.Message err = pages["account"].Execute(w, AccountResponse{
sessionError := session.Error Account: account,
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, TOTPs: totps,
}) })
if err != nil { if err != nil {
@ -75,296 +47,299 @@ func accountIndexHandler(app *model.AppState) http.Handler {
}) })
} }
func changePasswordHandler(app *model.AppState) http.Handler { func LoginHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
r.ParseForm()
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
newPassword := r.Form.Get("new-password")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
session.Account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, session.Account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}
func deleteAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
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 {
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
}
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
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)
})
}
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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
type totpSetupData struct { account, err := controller.GetAccountByRequest(db, r)
Session *model.Session
}
session := r.Context().Value("session").(*model.Session)
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 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
} }
return
}
if r.Method != http.MethodPost { err = pages["login"].Execute(w, TemplateData{})
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 { if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
} }
return return
} }
qrBase64Image, err := controller.GenerateQRCode( type LoginResponse struct {
controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) Account *model.Account
if err != nil { Token string
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) Message string
} }
err = totpConfirmTemplate.Execute(w, totpConfirmData{ render := func(data LoginResponse) {
Session: session, err := pages["login"].Execute(w, data)
TOTP: &totp, if err != nil {
NameEscaped: url.PathEscape(totp.Name), fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
QRBase64Image: qrBase64Image, http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
})
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 return
} }
} }
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) if r.Method != http.MethodPost {
if err != nil { http.NotFound(w, r);
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 return
} }
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name) err := r.ParseForm()
if err != nil {
render(LoginResponse{ Message: "Malformed request." })
return
}
controller.SetSessionError(app.DB, session, "") type LoginRequest struct {
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) Username string `json:"username"`
http.Redirect(w, r, "/admin/account", http.StatusFound) 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"),
}
account, err := controller.GetAccount(db, credentials.Username)
if err != nil {
render(LoginResponse{ Message: "Invalid username or password" })
return
}
if account == nil {
render(LoginResponse{ Message: "Invalid username or password" })
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
render(LoginResponse{ Message: "Invalid username or password" })
return
}
totps, err := controller.GetTOTPsForAccount(db, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
render(LoginResponse{ Message: "Something went wrong. Please try again." })
return
}
if len(totps) > 0 {
success := false
for _, totp := range totps {
check := controller.GenerateTOTP(totp.Secret, 0)
if check == credentials.TOTP {
success = true
break
}
}
if !success {
render(LoginResponse{ Message: "Invalid TOTP" })
return
}
} else {
// TODO: user should be prompted to add 2FA method
}
// login success!
token, err := controller.CreateToken(db, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
render(LoginResponse{ Message: "Something went wrong. Please try again." })
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
render(LoginResponse{ Account: account, Token: token.Token })
}) })
} }
func totpDeleteHandler(app *model.AppState) http.Handler { func LogoutHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if len(r.URL.Path) < 2 { tokenStr := controller.GetTokenFromRequest(db, r)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return 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
}
} }
name := r.URL.Path[1:]
session := r.Context().Value("session").(*model.Session) 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)
})
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) func createAccountHandler(db *sqlx.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkAccount, err := controller.GetAccountByRequest(db, r)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) fmt.Printf("WARN: Failed to fetch account: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
http.Redirect(w, r, "/admin/account", http.StatusFound)
return return
} }
if totp == nil { 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 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) err = r.ParseForm()
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err) render(CreateAccountResponse{
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") Message: "Malformed data.",
http.Redirect(w, r, "/admin/account", http.StatusFound) })
return return
} }
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) 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"),
}
controller.SetSessionError(app.DB, session, "") // make sure code exists in DB
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) invite, err := controller.GetInvite(db, credentials.Invite)
http.Redirect(w, r, "/admin/account", http.StatusFound) 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
}
}) })
} }

View file

@ -1,19 +1,20 @@
package admin package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/controller" "arimelody-web/controller"
) )
func serveArtist(app *model.AppState) http.Handler { func serveArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/") slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0] id := slices[0]
artist, err := controller.GetArtist(app.DB, id) artist, err := controller.GetArtist(global.DB, id)
if err != nil { if err != nil {
if artist == nil { if artist == nil {
http.NotFound(w, r) http.NotFound(w, r)
@ -24,7 +25,7 @@ func serveArtist(app *model.AppState) http.Handler {
return return
} }
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) credits, err := controller.GetArtistCredits(global.DB, artist.ID, true)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -32,15 +33,15 @@ func serveArtist(app *model.AppState) http.Handler {
} }
type ArtistResponse struct { type ArtistResponse struct {
Session *model.Session Account *model.Account
Artist *model.Artist Artist *model.Artist
Credits []*model.Credit Credits []*model.Credit
} }
session := r.Context().Value("session").(*model.Session) account := r.Context().Value("account").(*model.Account)
err = artistTemplate.Execute(w, ArtistResponse{ err = pages["artist"].Execute(w, ArtistResponse{
Session: session, Account: account,
Artist: artist, Artist: artist,
Credits: credits, Credits: credits,
}) })

View file

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

View file

@ -7,7 +7,7 @@
<h3 class="release-title"> <h3 class="release-title">
<a href="/admin/release/{{.ID}}">{{.Title}}</a> <a href="/admin/release/{{.ID}}">{{.Title}}</a>
<small> <small>
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span> <span title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
{{if not .Visible}}(hidden){{end}} {{if not .Visible}}(hidden){{end}}
</small> </small>
</h3> </h3>

View file

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

View file

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

View file

@ -1,462 +1,105 @@
package admin package admin
import ( import (
"context" "context"
"database/sql" "fmt"
"fmt" "net/http"
"net/http" "os"
"os" "path/filepath"
"path/filepath"
"strings"
"time"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/model"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt" "github.com/jmoiron/sqlx"
) )
func Handler(app *model.AppState) http.Handler { func Handler(db *sqlx.DB) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/login", LoginHandler(db))
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") mux.Handle("/register", createAccountHandler(db))
if err != nil { mux.Handle("/logout", RequireAccount(db, LogoutHandler(db)))
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err) mux.Handle("/account", RequireAccount(db, AccountHandler(db)))
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("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", RequireAccount(db, http.StripPrefix("/release", serveRelease())))
mux.Handle("/", requireAccount(AdminIndexHandler(app))) mux.Handle("/artist/", RequireAccount(db, http.StripPrefix("/artist", serveArtist())))
mux.Handle("/track/", RequireAccount(db, http.StripPrefix("/track", serveTrack())))
// response wrapper to make sure a session cookie exists mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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 != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
session := r.Context().Value("session").(*model.Session) account, err := controller.GetAccountByRequest(db, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
}
if account == nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true) releases, err := controller.GetAllReleases(db, false, 0, true)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
artists, err := controller.GetAllArtists(app.DB) artists, err := controller.GetAllArtists(db)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
tracks, err := controller.GetOrphanTracks(app.DB) tracks, err := controller.GetOrphanTracks(db)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
type IndexData struct { type IndexData struct {
Session *model.Session Account *model.Account
Releases []*model.Release Releases []*model.Release
Artists []*model.Artist Artists []*model.Artist
Tracks []*model.Track Tracks []*model.Track
} }
err = indexTemplate.Execute(w, IndexData{ err = pages["index"].Execute(w, IndexData{
Session: session, Account: account,
Releases: releases, Releases: releases,
Artists: artists, Artists: artists,
Tracks: tracks, Tracks: tracks,
}) })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}))
return mux
}
func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account, err := controller.GetAccountByRequest(db, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
}
})
}
func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
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.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 return
} }
if account == nil { if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
err = controller.SetSessionAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
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 (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func logoutHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Expires: time.Now(),
Path: "/",
})
err = 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 // TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound) http.Redirect(w, r, "/admin/login", http.StatusFound)
return return
} }
next.ServeHTTP(w, r)
ctx := context.WithValue(r.Context(), "account", account)
next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
@ -480,63 +123,3 @@ func staticHandler() http.Handler {
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
}) })
} }
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session == nil {
// create a new session
session, err = controller.CreateSession(app.DB, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Value: session.Token,
Expires: session.ExpiresAt,
Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
HttpOnly: true,
Path: "/",
})
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
if err != nil {
fmt.Fprintf(
os.Stderr,
"WARN: Failed to increment login failures for \"%s\": %v\n",
account.Username,
err,
)
app.Log.Warn(
log.TYPE_ACCOUNT,
"Failed to increment login failures for \"%s\"",
account.Username,
)
}
if locked {
app.Log.Warn(
log.TYPE_ACCOUNT,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
}
return locked
}

View file

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

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

View file

@ -24,7 +24,7 @@ nav {
justify-content: left; justify-content: left;
background: #f8f8f8; background: #f8f8f8;
border-radius: 4px; border-radius: .5em;
border: 1px solid #808080; border: 1px solid #808080;
} }
nav .icon { nav .icon {
@ -85,15 +85,6 @@ a img.icon {
height: .8em; height: .8em;
} }
code {
background: #303030;
color: #f0f0f0;
padding: .23em .3em;
border-radius: 4px;
}
.card { .card {
margin-bottom: 1em; margin-bottom: 1em;
} }
@ -102,6 +93,13 @@ code {
margin: 0 0 .5em 0; margin: 0 0 .5em 0;
} }
/*
.card h3,
.card p {
margin: 0;
}
*/
.card-title { .card-title {
margin-bottom: 1em; margin-bottom: 1em;
display: flex; display: flex;
@ -129,34 +127,20 @@ code {
#message,
#error {
margin: 0 0 1em 0;
padding: 1em;
border-radius: 4px;
background: #ffffff;
border: 1px solid #888;
}
#message {
background: #a9dfff;
border-color: #599fdc;
}
#error { #error {
background: #ffa9b8; background: #ffa9b8;
border-color: #dc5959; border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
} }
a.delete:not(.button) {
color: #d22828;
}
button, .button { button, .button {
padding: .5em .8em; padding: .5em .8em;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
border-radius: 4px; border-radius: .5em;
border: 1px solid #a0a0a0; border: 1px solid #a0a0a0;
background: #f0f0f0; background: #f0f0f0;
color: inherit; color: inherit;
@ -170,59 +154,35 @@ button:active, .button:active {
border-color: #808080; border-color: #808080;
} }
.button, button { button {
color: inherit; color: inherit;
} }
.button.new, button.new { button.new {
background: #c4ff6a; background: #c4ff6a;
border-color: #84b141; border-color: #84b141;
} }
.button.save, button.save { button.save {
background: #6fd7ff; background: #6fd7ff;
border-color: #6f9eb0; border-color: #6f9eb0;
} }
.button.delete, button.delete { button.delete {
background: #ff7171; background: #ff7171;
border-color: #7d3535; border-color: #7d3535;
} }
.button:hover, button:hover { button:hover {
background: #fff; background: #fff;
border-color: #d0d0d0; border-color: #d0d0d0;
} }
.button:active, button:active { button:active {
background: #d0d0d0; background: #d0d0d0;
border-color: #808080; border-color: #808080;
} }
.button[disabled], button[disabled] { button[disabled] {
background: #d0d0d0 !important; background: #d0d0d0 !important;
border-color: #808080 !important; border-color: #808080 !important;
opacity: .5; opacity: .5;
cursor: not-allowed !important; 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,18 +1,28 @@
@import url("/admin/static/index.css"); @import url("/admin/static/index.css");
div.card { form#change-password {
margin-bottom: 2rem; width: 100%;
display: flex;
flex-direction: column;
align-items: start;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
} }
label { label {
width: auto; width: 100%;
margin: 0; margin: 1rem 0 .5rem 0;
display: flex; display: block;
align-items: center; color: #10101080;
color: inherit;
} }
input { input {
width: min(20rem, calc(100% - 1rem)); width: 100%;
margin: .5rem 0; margin: .5rem 0;
padding: .3rem .5rem; padding: .3rem .5rem;
display: block; display: block;
@ -23,11 +33,18 @@ input {
color: inherit; color: inherit;
} }
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
.mfa-device { .mfa-device {
padding: .75em; padding: .75em;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
border-radius: 8px; border-radius: .5em;
margin-bottom: .5em; margin-bottom: .5em;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

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

View file

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

View file

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

View file

@ -1,5 +1,23 @@
@import url("/admin/static/release-list-item.css"); @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 { .artist {
margin-bottom: .5em; margin-bottom: .5em;
padding: .5em; padding: .5em;
@ -8,7 +26,7 @@
align-items: center; align-items: center;
gap: .5em; gap: .5em;
border-radius: 8px; border-radius: .5em;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }
@ -31,7 +49,7 @@
flex-direction: column; flex-direction: column;
gap: .5em; gap: .5em;
border-radius: 8px; border-radius: .5em;
background: #f8f8f8f8; background: #f8f8f8f8;
border: 1px solid #808080; border: 1px solid #808080;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,47 +0,0 @@
{{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,7 +3,15 @@
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css"> <link rel="stylesheet" href="/admin/static/admin.css">
<style> <style>
form#login { p a {
color: #2a67c8;
}
a.discord {
color: #5865F2;
}
form {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -18,33 +26,61 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input { input {
width: calc(100% - 1rem - 2px); width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
} }
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
{{if .Session.Message.Valid}} {{if .Message}}
<p id="message">{{html .Session.Message.String}}</p> <p id="error">{{.Message}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}} {{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"> <form action="/admin/login" method="POST" id="login">
<h1>Log In</h1>
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required autofocus> <input type="text" name="username" value="" autocomplete="username">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required> <input type="password" name="password" value="" autocomplete="current-password">
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code">
</div> </div>
<button type="submit" class="save">Login</button> <button type="submit" class="save">Login</button>
</form> </form>
{{end}}
</main> </main>
{{end}} {{end}}

View file

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

View file

@ -1,68 +0,0 @@
{{define "head"}}
<title>Audit Logs - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/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

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

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

202
api/account.go Normal file
View file

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

View file

@ -1,33 +1,44 @@
package api package api
import ( import (
"context" "fmt"
"errors" "net/http"
"fmt" "strings"
"net/http"
"os"
"strings"
"arimelody-web/controller" "arimelody-web/admin"
"arimelody-web/model" "arimelody-web/controller"
"github.com/jmoiron/sqlx"
) )
func Handler(app *model.AppState) http.Handler { func Handler(db *sqlx.DB) http.Handler {
mux := http.NewServeMux() 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 // 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 // ARTIST ENDPOINTS
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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] var artistID = strings.Split(r.URL.Path[1:], "/")[0]
artist, err := controller.GetArtist(app.DB, artistID) artist, err := controller.GetArtist(db, artistID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err) fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -35,13 +46,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/artist/{id} // GET /api/v1/artist/{id}
ServeArtist(app, artist).ServeHTTP(w, r) ServeArtist(artist).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/artist/{id} (admin) // PUT /api/v1/artist/{id} (admin)
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r) admin.RequireAccount(db, UpdateArtist(artist)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin) // DELETE /api/v1/artist/{id} (admin)
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r) admin.RequireAccount(db, DeleteArtist(artist)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -50,10 +61,10 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/artist // GET /api/v1/artist
ServeAllArtists(app).ServeHTTP(w, r) ServeAllArtists().ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/artist (admin) // POST /api/v1/artist (admin)
requireAccount(CreateArtist(app)).ServeHTTP(w, r) admin.RequireAccount(db, CreateArtist()).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -63,13 +74,13 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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] var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
release, err := controller.GetRelease(app.DB, releaseID, true) release, err := controller.GetRelease(db, releaseID, true)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err) fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -77,13 +88,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/music/{id} // GET /api/v1/music/{id}
ServeRelease(app, release).ServeHTTP(w, r) ServeRelease(release).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/music/{id} (admin) // PUT /api/v1/music/{id} (admin)
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r) admin.RequireAccount(db, UpdateRelease(release)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin) // DELETE /api/v1/music/{id} (admin)
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r) admin.RequireAccount(db, DeleteRelease(release)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -92,10 +103,10 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/music // GET /api/v1/music
ServeCatalog(app).ServeHTTP(w, r) ServeCatalog().ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/music (admin) // POST /api/v1/music (admin)
requireAccount(CreateRelease(app)).ServeHTTP(w, r) admin.RequireAccount(db, CreateRelease()).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -105,13 +116,13 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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] var trackID = strings.Split(r.URL.Path[1:], "/")[0]
track, err := controller.GetTrack(app.DB, trackID) track, err := controller.GetTrack(db, trackID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err) fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -119,13 +130,13 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/track/{id} (admin) // GET /api/v1/track/{id} (admin)
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r) admin.RequireAccount(db, ServeTrack(track)).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/track/{id} (admin) // PUT /api/v1/track/{id} (admin)
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r) admin.RequireAccount(db, UpdateTrack(track)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin) // DELETE /api/v1/track/{id} (admin)
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r) admin.RequireAccount(db, DeleteTrack(track)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -134,66 +145,14 @@ func Handler(app *model.AppState) http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/track (admin) // GET /api/v1/track (admin)
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r) admin.RequireAccount(db, ServeAllTracks()).ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/track (admin) // POST /api/v1/track (admin)
requireAccount(CreateTrack(app)).ServeHTTP(w, r) admin.RequireAccount(db, CreateTrack()).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
})) }))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return mux
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

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

View file

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

View file

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

View file

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

9
bundle.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/bash
# simple script to pack up arimelody.me for production distribution
if [ ! -f arimelody-web ]; then
echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first."
exit 1
fi
tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

61
controller/token.go Normal file
View file

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

View file

@ -1,25 +1,25 @@
package controller package controller
import ( import (
"arimelody-web/model" "arimelody-web/model"
"crypto/hmac" "crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha1" "crypto/sha1"
"encoding/base32" "encoding/base32"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
const TOTP_SECRET_LENGTH = 32 const TOTP_SECRET_LENGTH = 32
const TOTP_TIME_STEP int64 = 30 const TIME_STEP int64 = 30
const TOTP_CODE_LENGTH = 6 const CODE_LENGTH = 6
func GenerateTOTP(secret string, timeStepOffset int) string { func GenerateTOTP(secret string, timeStepOffset int) string {
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) 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") fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
} }
counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset) counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset)
counterBytes := make([]byte, 8) counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter)) binary.BigEndian.PutUint64(counterBytes, uint64(counter))
@ -37,9 +37,9 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
offset := hash[len(hash) - 1] & 0x0f offset := hash[len(hash) - 1] & 0x0f
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH)) code := binaryCode % int32(math.Pow10(CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code) return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code)
} }
func GenerateTOTPSecret(length int) string { func GenerateTOTPSecret(length int) string {
@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string {
query := url.Query() query := url.Query()
query.Set("secret", secret) query.Set("secret", secret)
query.Set("issuer", "arimelody.me") query.Set("issuer", "arimelody.me")
// query.Set("algorithm", "SHA1") query.Set("algorithm", "SHA1")
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH)) query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH))
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP)) query.Set("period", fmt.Sprintf("%d", TIME_STEP))
url.RawQuery = query.Encode() url.RawQuery = query.Encode()
return url.String() return url.String()
@ -78,7 +78,7 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
err := db.Select( err := db.Select(
&totps, &totps,
"SELECT * FROM totp " + "SELECT * FROM totp " +
"WHERE account=$1 AND confirmed=true " + "WHERE account=$1 " +
"ORDER BY created_at ASC", "ORDER BY created_at ASC",
accountID, accountID,
) )
@ -89,36 +89,14 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
return totps, nil 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) { func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
totp := model.TOTP{} totp := model.TOTP{}
err := db.Get( err := db.Get(
&totp, &totp,
"SELECT * FROM totp " + "SELECT * FROM totp " +
"WHERE account=$1 AND name=$2", "WHERE account=$1",
accountID, accountID,
name,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
@ -130,15 +108,6 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
return &totp, nil 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 { func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO totp (account, name, secret) " + "INSERT INTO totp (account, name, secret) " +
@ -158,8 +127,3 @@ func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
) )
return err return err
} }
func DeleteUnconfirmedTOTPs(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM totp WHERE confirmed=false")
return err
}

View file

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

View file

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

View file

@ -1,201 +0,0 @@
package cursor
import (
"arimelody-web/model"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type CursorClient struct {
ID int32
Conn *websocket.Conn
Route string
X float32
Y float32
Click bool
Disconnected bool
}
type CursorMessage struct {
Data []byte
Route string
Exclude []*CursorClient
}
func (client *CursorClient) Send(data []byte) {
err := client.Conn.WriteMessage(websocket.TextMessage, data)
if err != nil {
client.Disconnect()
}
}
func (client *CursorClient) Disconnect() {
client.Disconnected = true
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("leave:%d", client.ID)),
client.Route,
[]*CursorClient{},
}
}
var clients = make(map[int32]*CursorClient)
var broadcast = make(chan CursorMessage)
var mutex = &sync.Mutex{}
func StartCursor(app *model.AppState) {
var includes = func (clients []*CursorClient, client *CursorClient) bool {
for _, c := range clients {
if c.ID == client.ID { return true }
}
return false
}
log("Cursor message handler ready!")
for {
message := <-broadcast
mutex.Lock()
for _, client := range clients {
if client.Route != message.Route { continue }
if includes(message.Exclude, client) { continue }
client.Send(message.Data)
}
mutex.Unlock()
}
}
func handleClient(client *CursorClient) {
msgType, message, err := client.Conn.ReadMessage()
if err != nil {
client.Disconnect()
return
}
if msgType != websocket.TextMessage { return }
args := strings.Split(string(message), ":")
if len(args) == 0 { return }
switch args[0] {
case "loc":
if len(args) < 2 { return }
client.Route = args[1]
mutex.Lock()
for otherClientID, otherClient := range clients {
if otherClientID == client.ID || otherClient.Route != client.Route { continue }
client.Send([]byte(fmt.Sprintf("join:%d", otherClientID)))
client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClientID, otherClient.X, otherClient.Y)))
}
mutex.Unlock()
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("join:%d", client.ID)),
client.Route,
[]*CursorClient{ client },
}
case "char":
if len(args) < 2 { return }
// haha, turns out using ':' as a separator means you can't type ':'s
// i should really be writing byte packets, not this nonsense
msg := byte(':')
if len(args[1]) > 0 {
msg = args[1][0]
}
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("char:%d:%c", client.ID, msg)),
client.Route,
[]*CursorClient{ client },
}
case "nochar":
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("nochar:%d", client.ID)),
client.Route,
[]*CursorClient{ client },
}
case "click":
if len(args) < 2 { return }
click := 0
if args[1][0] == '1' {
click = 1
}
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("click:%d:%d", client.ID, click)),
client.Route,
[]*CursorClient{ client },
}
case "pos":
if len(args) < 3 { return }
x, err := strconv.ParseFloat(args[1], 32)
y, err := strconv.ParseFloat(args[2], 32)
if err != nil { return }
client.X = float32(x)
client.Y = float32(y)
broadcast <- CursorMessage{
[]byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)),
client.Route,
[]*CursorClient{ client },
}
}
}
func Handler(app *model.AppState) http.HandlerFunc {
var upgrader = websocket.Upgrader{
CheckOrigin: func (r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == app.Config.BaseUrl
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log("Failed to upgrade to WebSocket connection: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer conn.Close()
client := CursorClient{
ID: rand.Int31(),
Conn: conn,
X: 0.0,
Y: 0.0,
Disconnected: false,
}
err = client.Conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("id:%d", client.ID)))
if err != nil {
client.Conn.Close()
return
}
mutex.Lock()
clients[client.ID] = &client
mutex.Unlock()
// log("Client connected: %s (%s)", fmt.Sprintf("0x%08x", client.ID), client.Conn.RemoteAddr().String())
for {
if client.Disconnected {
mutex.Lock()
delete(clients, client.ID)
client.Conn.Close()
mutex.Unlock()
return
}
handleClient(&client)
}
})
}
func log(format string, args ...any) {
logString := fmt.Sprintf(format, args...)
fmt.Printf("[%s] [CURSOR] %s\n", time.Now().Format(time.UnixDate), logString)
}

View file

@ -1,17 +1,38 @@
package discord package discord
import ( import (
"arimelody-web/model" "encoding/json"
"encoding/json" "errors"
"errors" "fmt"
"fmt" "net/http"
"net/http" "net/url"
"net/url" "strings"
"strings"
"arimelody-web/global"
) )
const API_ENDPOINT = "https://discord.com/api/v10" 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 ( type (
AccessTokenResponse struct { AccessTokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
@ -47,15 +68,15 @@ type (
} }
) )
func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) { func GetOAuthTokenFromCode(code string) (string, error) {
// let's get an oauth token! // let's get an oauth token!
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT), req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT),
strings.NewReader(url.Values{ strings.NewReader(url.Values{
"client_id": {app.Config.Discord.ClientID}, "client_id": {CLIENT_ID},
"client_secret": {app.Config.Discord.Secret}, "client_secret": {CLIENT_SECRET},
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"code": {code}, "code": {code},
"redirect_uri": {GetOAuthCallbackURI(app.Config.BaseUrl)}, "redirect_uri": {OAUTH_CALLBACK_URI},
}.Encode())) }.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
@ -94,15 +115,3 @@ func GetDiscordUserFromAuth(token string) (DiscordUser, error) {
return auth_info.User, nil 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,28 +1,49 @@
package controller package global
import ( import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"arimelody-web/model" "github.com/jmoiron/sqlx"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2"
) )
func GetConfig() model.Config { type (
dbConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
Name string `toml:"name"`
User string `toml:"user"`
Pass string `toml:"pass"`
}
discordConfig struct {
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"`
DB dbConfig `toml:"db"`
Discord discordConfig `toml:"discord"`
}
)
var Config = func() config {
configFile := os.Getenv("ARIMELODY_CONFIG") configFile := os.Getenv("ARIMELODY_CONFIG")
if configFile == "" { if configFile == "" {
configFile = "config.toml" configFile = "config.toml"
} }
config := model.Config{ config := config{
BaseUrl: "https://arimelody.me", BaseUrl: "https://arimelody.me",
Host: "0.0.0.0",
Port: 8080, Port: 8080,
TrustedProxies: []string{ "127.0.0.1" }, DB: dbConfig{
DB: model.DBConfig{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 5432, Port: 5432,
User: "arimelody", User: "arimelody",
@ -42,22 +63,23 @@ func GetConfig() model.Config {
err = toml.Unmarshal([]byte(data), &config) err = toml.Unmarshal([]byte(data), &config)
if err != nil { if err != nil {
panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err)) fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
os.Exit(1)
} }
err = handleConfigOverrides(&config) err = handleConfigOverrides(&config)
if err != nil { if err != nil {
panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err)) fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
os.Exit(1)
} }
return config return config
} }()
func handleConfigOverrides(config *model.Config) error { func handleConfigOverrides(config *config) error {
var err error var err error
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } 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 { if env, has := os.LookupEnv("ARIMELODY_PORT"); has {
config.Port, err = strconv.ParseInt(env, 10, 0) config.Port, err = strconv.ParseInt(env, 10, 0)
if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) } if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) }
@ -77,9 +99,7 @@ func handleConfigOverrides(config *model.Config) error {
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env } if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env } if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
return nil return nil
} }
var DB *sqlx.DB

3
global/const.go Normal file
View file

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

101
global/funcs.go Normal file
View file

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

7
go.mod
View file

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

8
go.sum
View file

@ -2,17 +2,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=

View file

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

469
main.go
View file

@ -1,32 +1,25 @@
package main package main
import ( import (
"bufio" "errors"
"errors" "fmt"
"fmt" "log"
stdLog "log" "net/http"
"math" "os"
"math/rand" "path/filepath"
"net" "strings"
"net/http" "time"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"arimelody-web/admin" "arimelody-web/admin"
"arimelody-web/api" "arimelody-web/api"
"arimelody-web/colour" "arimelody-web/controller"
"arimelody-web/controller" "arimelody-web/global"
"arimelody-web/cursor" "arimelody-web/model"
"arimelody-web/log" "arimelody-web/templates"
"arimelody-web/model" "arimelody-web/view"
"arimelody-web/view"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
) )
// used for database migrations // used for database migrations
@ -37,51 +30,48 @@ const DEFAULT_PORT int64 = 8080
func main() { func main() {
fmt.Printf("made with <3 by ari melody\n\n") fmt.Printf("made with <3 by ari melody\n\n")
app := model.AppState{ // TODO: refactor `global` to `AppState`
Config: controller.GetConfig(), // this should contain `Config` and `DB`, and be passed through to all
Twitch: nil, // handlers that need it. it's better than weird static globals everywhere!
}
// initialise database connection // initialise database connection
if app.Config.DB.Host == "" { if global.Config.DB.Host == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
if app.Config.DB.Name == "" { if global.Config.DB.Name == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
if app.Config.DB.User == "" { if global.Config.DB.User == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
if app.Config.DB.Pass == "" { if global.Config.DB.Pass == "" {
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
os.Exit(1) os.Exit(1)
} }
var err error var err error
app.DB, err = sqlx.Connect( global.DB, err = sqlx.Connect(
"postgres", "postgres",
fmt.Sprintf( fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable", "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
app.Config.DB.Host, global.Config.DB.Host,
app.Config.DB.Port, global.Config.DB.Port,
app.Config.DB.User, global.Config.DB.User,
app.Config.DB.Name, global.Config.DB.Name,
app.Config.DB.Pass, global.Config.DB.Pass,
), ),
) )
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
os.Exit(1) os.Exit(1)
} }
app.DB.SetConnMaxLifetime(time.Minute * 3) global.DB.SetConnMaxLifetime(time.Minute * 3)
app.DB.SetMaxOpenConns(10) global.DB.SetMaxOpenConns(10)
app.DB.SetMaxIdleConns(10) global.DB.SetMaxIdleConns(10)
defer app.DB.Close() defer global.DB.Close()
app.Log = log.Logger{ DB: app.DB }
// handle command arguments // handle command arguments
if len(os.Args) > 1 { if len(os.Args) > 1 {
@ -95,36 +85,31 @@ func main() {
} }
username := os.Args[2] username := os.Args[2]
totpName := os.Args[3] totpName := os.Args[3]
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
account, err := controller.GetAccountByUsername(app.DB, username) account, err := controller.GetAccount(global.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP { totp := model.TOTP {
AccountID: account.ID, AccountID: account.ID,
Name: totpName, Name: totpName,
Secret: string(secret), Secret: string(secret),
} }
err = controller.CreateTOTP(app.DB, &totp) err = controller.CreateTOTP(global.DB, &totp)
if err != nil { if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") { fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
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) 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) url := controller.GenerateTOTPURI(account.Username, totp.Secret)
fmt.Printf("%s\n", url) fmt.Printf("%s\n", url)
return return
@ -137,24 +122,23 @@ func main() {
username := os.Args[2] username := os.Args[2]
totpName := os.Args[3] totpName := os.Args[3]
account, err := controller.GetAccountByUsername(app.DB, username) account, err := controller.GetAccount(global.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
err = controller.DeleteTOTP(app.DB, account.ID, totpName) err = controller.DeleteTOTP(global.DB, account.ID, totpName)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
os.Exit(1) 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) fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return return
@ -165,20 +149,20 @@ func main() {
} }
username := os.Args[2] username := os.Args[2]
account, err := controller.GetAccountByUsername(app.DB, username) account, err := controller.GetAccount(global.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) totps, err := controller.GetTOTPsForAccount(global.DB, account.ID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -198,25 +182,25 @@ func main() {
username := os.Args[2] username := os.Args[2]
totpName := os.Args[3] totpName := os.Args[3]
account, err := controller.GetAccountByUsername(app.DB, username) account, err := controller.GetAccount(global.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
totp, err := controller.GetTOTP(app.DB, account.ID, totpName) totp, err := controller.GetTOTP(global.DB, account.ID, totpName)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
os.Exit(1) os.Exit(1)
} }
if totp == nil { if totp == nil {
fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
os.Exit(1) os.Exit(1)
} }
@ -224,103 +208,49 @@ func main() {
fmt.Printf("%s\n", code) fmt.Printf("%s\n", code)
return 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": case "createInvite":
fmt.Printf("Creating invite...\n") fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
os.Exit(1) os.Exit(1)
} }
app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code) fmt.Printf("Here you go! This code expires in 24 hours: %s\n", 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 return
case "purgeInvites": case "purgeInvites":
fmt.Printf("Deleting all invites...\n") fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(app.DB) err := controller.DeleteAllInvites(global.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
os.Exit(1) os.Exit(1)
} }
app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
fmt.Printf("Invites deleted successfully.\n") fmt.Printf("Invites deleted successfully.\n")
return return
case "listAccounts": case "listAccounts":
accounts, err := controller.GetAllAccounts(app.DB) accounts, err := controller.GetAllAccounts(global.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err)
os.Exit(1) os.Exit(1)
} }
for _, account := range accounts { for _, account := range accounts {
email := "<none>"
if account.Email.Valid { email = account.Email.String }
fmt.Printf( fmt.Printf(
"User: %s\n" + "User: %s\n" +
"\tID: %s\n" + "\tID: %s\n" +
"\tEmail: %s\n" + "\tEmail: %s\n" +
"\tCreated: %s\n" + "\tCreated: %s\n",
"\tLocked: %t\n",
account.Username, account.Username,
account.ID, account.ID,
email, account.Email,
account.CreatedAt, account.CreatedAt,
account.Locked,
) )
} }
return 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": case "deleteAccount":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
@ -329,14 +259,14 @@ func main() {
username := os.Args[2] username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username) fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username) account, err := controller.GetAccount(global.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1) os.Exit(1)
} }
if account == nil { if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
os.Exit(1) os.Exit(1)
} }
@ -347,94 +277,15 @@ func main() {
return return
} }
err = controller.DeleteAccount(app.DB, account.ID) err = controller.DeleteAccount(global.DB, username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
os.Exit(1) 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) fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return return
case "lockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.LockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
return
case "unlockAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.UnlockAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
os.Exit(1)
}
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
return
case "logs":
// 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 // command help
@ -444,41 +295,30 @@ func main() {
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" + "listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\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" + "testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
"cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\n" +
"\n" + "\n" +
"createInvite:\n\tCreates an invite code to register new accounts.\n" + "createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" +
"listAccounts:\n\tLists all active accounts.\n", "listAccounts:\n\tLists all active accounts.\n",
"deleteAccount <username>:\n\tDeletes the account under `username`.\n", "deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
"lockAccount <username>:\n\tLocks the account under `username`.\n",
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
"logs:\n\tShows system logs.\n",
) )
return return
} }
// handle DB migrations // handle DB migrations
controller.CheckDBVersionAndMigrate(app.DB) controller.CheckDBVersionAndMigrate(global.DB)
if app.Config.Twitch != nil {
err = controller.TwitchSetup(&app)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
}
}
// initial invite code // initial invite code
accountsCount := 0 accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) } if err != nil { panic(err) }
if accountsCount == 0 { if accountsCount == 0 {
_, err := app.DB.Exec("DELETE FROM invite") _, err := global.DB.Exec("DELETE FROM invite")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
os.Exit(1) os.Exit(1)
} }
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1) os.Exit(1)
@ -487,145 +327,66 @@ func main() {
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
} }
// delete expired sessions
err = controller.DeleteExpiredSessions(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
os.Exit(1)
}
// delete expired invites // delete expired invites
err = controller.DeleteExpiredInvites(app.DB) err = controller.DeleteExpiredInvites(global.DB)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// clean up unconfirmed TOTP methods
err = controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
os.Exit(1)
}
go cursor.StartCursor(&app)
// start the web server! // start the web server!
mux := createServeMux(&app) mux := createServeMux()
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) fmt.Printf("Now serving at %s:%d\n", global.Config.BaseUrl, global.Config.Port)
stdLog.Fatal( log.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port), http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port),
HTTPLog(DefaultHeaders(mux)), global.HTTPLog(global.DefaultHeaders(mux)),
)) ))
} }
func createServeMux(app *model.AppState) *http.ServeMux { func createServeMux() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(global.DB)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) mux.Handle("/api/", http.StripPrefix("/api", api.Handler(global.DB)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(global.DB)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app)) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/", view.IndexHandler(app)) if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.Pages["index"].Execute(w, nil)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
staticHandler("public").ServeHTTP(w, r)
}))
return mux return mux
} }
var PoweredByStrings = []string{ func staticHandler(directory string) http.Handler {
"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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "arimelody.me") info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
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 { // does the file exist?
http.ResponseWriter if err != nil {
Status int if errors.Is(err, os.ErrNotExist) {
} http.NotFound(w, r)
return
func (lrw *LoggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { }
hijack, ok := lrw.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("Server does not support hijacking\n")
}
return hijack.Hijack()
}
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Status = status
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := LoggingResponseWriter{w, http.StatusOK}
next.ServeHTTP(&lrw, r)
after := time.Now()
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
elapsed := "<1"
if difference >= 1 {
elapsed = strconv.Itoa(difference)
} }
statusColour := colour.Reset // is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
if lrw.Status - 600 <= 0 { statusColour = colour.Red } http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
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,23 +1,15 @@
package model package model
import ( import "time"
"database/sql"
"time"
)
const COOKIE_TOKEN string = "AM_SESSION"
const MAX_LOGIN_FAIL_ATTEMPTS int = 3
type ( type (
Account struct { Account struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"` Password string `json:"password" db:"password"`
Email sql.NullString `json:"email" db:"email"` Email string `json:"email" db:"email"`
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` AvatarURL string `json:"avatar_url" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
Locked bool `json:"locked" db:"locked"`
Privileges []AccountPrivilege `json:"privileges"` Privileges []AccountPrivilege `json:"privileges"`
} }

View file

@ -1,47 +0,0 @@
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"`
}
TwitchConfig struct {
Broadcaster string `toml:"broadcaster"`
ClientID string `toml:"client_id"`
Secret string `toml:"secret"`
}
Config struct {
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
Host string `toml:"host"`
Port int64 `toml:"port"`
DataDirectory string `toml:"data_dir"`
TrustedProxies []string `toml:"trusted_proxies"`
DB DBConfig `toml:"db"`
Discord *DiscordConfig `toml:"discord"`
Twitch *TwitchConfig `toml:"twitch"`
}
AppState struct {
DB *sqlx.DB
Config Config
Log log.Logger
Twitch *TwitchState
}
)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
package model package model
import ( import (
"html/template" "html/template"
"strings" "strings"
"time" "time"
) )
type ( type (
@ -24,7 +24,6 @@ type (
Tracks []*Track `json:"tracks"` Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"` Credits []*Credit `json:"credits"`
Links []*Link `json:"links"` Links []*Link `json:"links"`
CreatedAt time.Time `json:"-" db:"created_at"`
} }
) )
@ -50,6 +49,10 @@ func (release Release) PrintReleaseDate() string {
return release.ReleaseDate.Format("02 January 2006") return release.ReleaseDate.Format("02 January 2006")
} }
func (release Release) GetReleaseYear() int {
return release.ReleaseDate.Year()
}
func (release Release) GetArtwork() string { func (release Release) GetArtwork() string {
if release.Artwork == "" { if release.Artwork == "" {
return "/img/default-cover-art.png" return "/img/default-cover-art.png"
@ -73,23 +76,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string {
names = append(names, credit.Artist.Name) names = append(names, credit.Artist.Name)
} }
return names return names
} }
func (release Release) PrintArtists(only_primary bool, ampersand bool) string { func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
names := release.GetUniqueArtistNames(only_primary) names := release.GetUniqueArtistNames(only_primary)
if len(names) == 0 { if len(names) == 0 {
return "Unknown Artist" return "Unknown Artist"
} else if len(names) == 1 { } else if len(names) == 1 {
return names[0] return names[0]
} }
if ampersand { if ampersand {
res := strings.Join(names[:len(names)-1], ", ") res := strings.Join(names[:len(names)-1], ", ")
res += " & " + names[len(names)-1] res += " & " + names[len(names)-1]
return res return res
} else { } else {
return strings.Join(names[:], ", ") return strings.Join(names[:], ", ")
} }
} }

View file

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

View file

@ -1,18 +0,0 @@
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"`
}

11
model/token.go Normal file
View file

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

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

View file

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

View file

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

Before

Width:  |  Height:  |  Size: 5.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 890 B

View file

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -1,26 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJMEExYKADsWIQTu
CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB jeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbAwULCQgHAgIiAgYVCgkICwIEFgID
iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ AQIeBwIXgAAKCRDPmYKckmeBiGCbAP4wTcLCU5ZlfSTJrFtGhQKWA6DxtUO7Cegk
+oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI Vu8SgkY3KgEA1/YqjZ1vSaqPDN4137vmhkhfduoYOjN0iptNj39u2wG4OARk1bTd
kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC EgorBgEEAZdVAQUBAQdAnA2drPzQBoXNdwIrFnovuF0CjX+8+8QSugCF4a5ZEXED
BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF AQgHiHgEGBYKACAWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbDAAKCRDP
ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P mYKckmeBiC/xAQD1hu4WcstR40lkUxMqhZ44wmizrDA+eGCdh7Ge3Gy79wEAx385
f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL GnYoNplMTA4BTGs7orV4WSfSkoBx0+px1UOewgs=
d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq =M1Bp
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----- -----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,106 +1,42 @@
const ARIMELODY_CONFIG_NAME = "arimelody.me-config"; function toggle_config_setting(config, name) {
if (config[name]) {
class Config { delete config[name];
_crt = false; update_config(config);
_cursor = false; return true;
_cursorFunMode = false; }
config[name] = true;
/** @type Map<string, Array<Function>> */ update_config(config);
#listeners = new Map(); return true;
constructor(values) {
function thisOrElse(values, name, defaultValue) {
if (values === null) return defaultValue;
if (values[name] === undefined) return defaultValue;
return values[name];
}
this.#listeners.set('crt', new Array());
this.crt = thisOrElse(values, 'crt', false);
this.#listeners.set('cursor', new Array());
this.cursor = thisOrElse(values, 'cursor', false);
this.#listeners.set('cursorFunMode', new Array());
this.cursorFunMode = thisOrElse(values, 'cursorFunMode', false);
this.save();
}
/**
* Appends a listener function to be called when the config value of `name`
* is changed.
*/
addListener(name, callback) {
const callbacks = this.#listeners.get(name);
if (!callbacks) return;
callbacks.push(callback);
}
/**
* Removes the listener function `callback` from the list of callbacks when
* the config value of `name` is changed.
*/
removeListener(name, callback) {
const callbacks = this.#listeners.get(name);
if (!callbacks) return;
callbacks.set(name, callbacks.filter(c => c !== callback));
}
save() {
localStorage.setItem(ARIMELODY_CONFIG_NAME, JSON.stringify({
crt: this.crt,
cursor: this.cursor,
cursorFunMode: this.cursorFunMode
}));
}
get crt() { return this._crt }
set crt(/** @type boolean */ enabled) {
this._crt = enabled;
this.save();
if (enabled) {
document.body.classList.add("crt");
} else {
document.body.classList.remove("crt");
}
document.getElementById('toggle-crt').className = enabled ? "" : "disabled";
this.#listeners.get('crt').forEach(callback => {
callback(this._crt);
})
}
get cursor() { return this._cursor }
set cursor(/** @type boolean */ value) {
this._cursor = value;
this.save();
this.#listeners.get('cursor').forEach(callback => {
callback(this._cursor);
})
}
get cursorFunMode() { return this._cursorFunMode }
set cursorFunMode(/** @type boolean */ value) {
this._cursorFunMode = value;
this.save();
this.#listeners.get('cursorFunMode').forEach(callback => {
callback(this._cursorFunMode);
})
}
} }
const config = (() => { function set_config_setting(config, name, value) {
let values = null; config[name] = value;
update_config(config);
return true;
}
const saved = localStorage.getItem(ARIMELODY_CONFIG_NAME); function clear_config_setting(config, name) {
if (saved) if (!config[name]) return false;
values = JSON.parse(saved); delete config[name];
update_config(config);
return true;
}
return new Config(values); 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");
}
}
document.getElementById("toggle-crt").addEventListener("click", () => { document.getElementById("toggle-crt").addEventListener("click", () => {
config.crt = !config.crt; toggle_config_setting(config, "disable_crt");
document.querySelector('div#overlay').toggleAttribute("hidden");
document.getElementById('toggle-crt').className = config.disable_crt ? "disabled" : "";
}); });
window.config = config;
export default config;

View file

@ -1,403 +0,0 @@
import config from './config.js';
const CURSOR_LERP_RATE = 1/100;
const CURSOR_FUNCHAR_RATE = 20;
const CURSOR_CHAR_MAX_LIFE = 5000;
const CURSOR_MAX_CHARS = 64;
/** @type HTMLCanvasElement */
let canvas;
/** @type CanvasRenderingContext2D */
let ctx;
/** @type Cursor */
let myCursor;
/** @type Map<number, Cursor> */
let cursors = new Map();
/** @type WebSocket */
let ws;
let running = false;
let lastUpdate = 0;
let cursorBoxHeight = 0;
let cursorBoxRadius = 0;
let cursorIDFontSize = 0;
let cursorCharFontSize = 0;
class Cursor {
#funCharCooldown = CURSOR_FUNCHAR_RATE;
/**
* @param {string} id
* @param {number} x
* @param {number} y
*/
constructor(id, x, y) {
this.id = id;
// real coordinates (canonical)
this.x = x;
this.y = y;
// render coordinates (interpolated)
this.rx = x;
this.ry = y;
this.msg = '';
/** @type Array<FunChar> */
this.funChars = new Array();
this.colour = randomColour();
this.click = false;
}
/**
* @param {number} deltaTime
*/
update(deltaTime) {
this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime;
this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime;
if (this.#funCharCooldown > 0)
this.#funCharCooldown -= deltaTime;
const x = this.rx * innerWidth - scrollX;
const y = this.ry * innerHeight - scrollY;
const onBackground = ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background');
if (config.cursorFunMode === true) {
if (this.msg.length > 0) {
if (this.#funCharCooldown <= 0) {
this.#funCharCooldown = CURSOR_FUNCHAR_RATE;
if (this.funChars.length >= CURSOR_MAX_CHARS) {
this.funChars.shift();
}
const yOffset = -10 / innerHeight;
const accelMultiplier = 0.002;
this.funChars.push(new FunChar(
this.x, this.y + yOffset,
(this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier,
this.msg));
}
}
this.funChars.forEach(char => {
if (char.life > CURSOR_CHAR_MAX_LIFE ||
char.y - scrollY > innerHeight ||
char.x < 0 ||
char.x * innerWidth - scrollX > innerWidth
) {
this.funChars = this.funChars.filter(c => c !== this);
return;
}
char.update(deltaTime);
});
} else if (this.msg.length > 0) {
ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace';
ctx.fillStyle = onBackground;
ctx.fillText(
this.msg,
(x + 6) * devicePixelRatio,
(y + -8) * devicePixelRatio);
}
const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches;
if (lightTheme)
ctx.filter = 'saturate(5) brightness(0.8)';
const idText = '0x' + this.id.toString(16).padStart(8, '0');
const colour = this.click ? onBackground : this.colour;
ctx.beginPath();
ctx.roundRect(
(x) * devicePixelRatio,
(y) * devicePixelRatio,
(12 + 7.2 * idText.length) * devicePixelRatio,
cursorBoxHeight,
cursorBoxRadius);
ctx.closePath();
ctx.fillStyle = lightTheme ? '#fff8' : '#0008';
ctx.fill();
ctx.strokeStyle = colour;
ctx.lineWidth = devicePixelRatio;
ctx.stroke();
ctx.font = cursorIDFontSize + 'px monospace';
ctx.fillStyle = colour;
ctx.fillText(
idText,
(x + 6) * devicePixelRatio,
(y + 14) * devicePixelRatio);
ctx.filter = '';
}
}
class FunChar {
/**
* @param {number} x
* @param {number} y
* @param {number} xa
* @param {number} ya
* @param {string} text
*/
constructor(x, y, xa, ya, text) {
this.x = x;
this.y = y;
this.xa = xa + Math.random() * .0005 - .00025;
this.ya = ya + Math.random() * -.00025;
this.r = this.xa * 1000;
this.ra = this.r * 0.01;
this.text = text;
this.life = 0;
}
/**
* @param {number} deltaTime
*/
update(deltaTime) {
this.life += deltaTime;
this.x += this.xa * deltaTime;
this.y += this.ya * deltaTime;
this.r += this.ra * deltaTime;
this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10);
const x = this.x * innerWidth - scrollX;
const y = this.y * innerHeight - scrollY;
const translateOffset = {
x: (x + 7.2) * devicePixelRatio,
y: (y - 7.2) * devicePixelRatio,
};
ctx.translate(translateOffset.x, translateOffset.y);
ctx.rotate(this.r);
ctx.translate(-translateOffset.x, -translateOffset.y);
ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace';
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background');
ctx.fillText(
this.text,
x * devicePixelRatio,
y * devicePixelRatio);
ctx.resetTransform();
}
}
/**
* @returns string
*/
function randomColour() {
const min = 128;
const range = 100;
const red = Math.round((min + Math.random() * range)).toString(16);
const green = Math.round((min + Math.random() * range)).toString(16);
const blue = Math.round((min + Math.random() * range)).toString(16);
return '#' + red + green + blue;
}
/**
* @param {MouseEvent} event
*/
let mouseMoveLock = false;
const mouseMoveCooldown = 1000/30;
function handleMouseMove(event) {
if (!myCursor) return;
const x = event.pageX / innerWidth;
const y = event.pageY / innerHeight;
const f = 10000; // four digit floating-point precision
if (!mouseMoveLock) {
mouseMoveLock = true;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send(`pos:${Math.round(x * f) / f}:${Math.round(y * f) / f}`);
setTimeout(() => {
mouseMoveLock = false;
}, mouseMoveCooldown);
}
myCursor.x = x;
myCursor.y = y;
}
function handleMouseDown() {
myCursor.click = true;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send('click:1');
}
function handleMouseUp() {
myCursor.click = false;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send('click:0');
}
/**
* @param {KeyboardEvent} event
*/
function handleKeyPress(event) {
if (event.key.length > 1) return;
if (event.metaKey || event.ctrlKey) return;
if (myCursor.msg === event.key) return;
if (ws && ws.readyState == WebSocket.OPEN)
ws.send(`char:${event.key}`);
myCursor.msg = event.key;
}
function handleKeyUp() {
if (ws && ws.readyState == WebSocket.OPEN)
ws.send(`nochar`);
myCursor.msg = '';
}
/**
* @param {number} timestamp
*/
function update(timestamp) {
if (!running) return;
const deltaTime = timestamp - lastUpdate;
lastUpdate = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
cursors.forEach(cursor => {
cursor.update(deltaTime);
});
requestAnimationFrame(update);
}
function handleWindowResize() {
canvas.width = innerWidth * devicePixelRatio;
canvas.height = innerHeight * devicePixelRatio;
cursorBoxHeight = 20 * devicePixelRatio;
cursorBoxRadius = 4 * devicePixelRatio;
cursorIDFontSize = 12 * devicePixelRatio;
cursorCharFontSize = 20 * devicePixelRatio;
}
function cursorSetup() {
if (running) throw new Error('Only one instance of Cursor can run at a time.');
running = true;
canvas = document.createElement('canvas');
canvas.id = 'cursors';
handleWindowResize();
document.body.appendChild(canvas);
ctx = canvas.getContext('2d');
myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2);
cursors.set(0, myCursor);
addEventListener('resize', handleWindowResize);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('keypress', handleKeyPress);
document.addEventListener('keyup', handleKeyUp);
requestAnimationFrame(update);
ws = new WebSocket('/cursor-ws');
ws.addEventListener('open', () => {
console.log('Cursor connected to server successfully.');
ws.send(`loc:${location.pathname}`);
});
ws.addEventListener('error', error => {
console.error('Cursor WebSocket error:', error);
});
ws.addEventListener('close', () => {
console.log('Cursor connection closed.');
});
ws.addEventListener('message', event => {
const args = String(event.data).split(':');
if (args.length == 0) return;
let id = 0;
/** @type Cursor */
let cursor;
if (args.length > 1) {
id = Number(args[1]);
cursor = cursors.get(id);
}
switch (args[0]) {
case 'id': {
myCursor.id = id;
break;
}
case 'join': {
if (id === myCursor.id) break;
cursors.set(id, new Cursor(id, 0, 0));
break;
}
case 'leave': {
if (!cursor || cursor === myCursor) break;
cursors.delete(id);
break;
}
case 'char': {
if (!cursor || cursor === myCursor) break;
cursor.msg = args[2];
break;
}
case 'nochar': {
if (!cursor || cursor === myCursor) break;
cursor.msg = '';
break;
}
case 'click': {
if (!cursor || cursor === myCursor) break;
cursor.click = args[2] == '1';
break;
}
case 'pos': {
if (!cursor || cursor === myCursor) break;
cursor.x = Number(args[2]);
cursor.y = Number(args[3]);
break;
}
default: {
console.warn('Cursor: Unknown command received from server:', args[0]);
break;
}
}
});
console.log(`Cursor tracking @ ${location.pathname}`);
}
function cursorDestroy() {
if (!running) return;
removeEventListener('resize', handleWindowResize);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('keypress', handleKeyPress);
document.removeEventListener('keyup', handleKeyUp);
ctx.clearRect(0, 0, canvas.width, canvas.height);
cursors.clear();
myCursor = null;
console.log(`Cursor no longer tracking.`);
running = false;
}
if (config.cursor === true) {
cursorSetup();
}
config.addListener('cursor', enabled => {
if (enabled === true)
cursorSetup();
else
cursorDestroy();
});

View file

@ -1,23 +0,0 @@
import { hijackClickEvent } from "./main.js";
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();
});
document.querySelectorAll("ul#projects li.project-item").forEach(projectItem => {
const link = projectItem.querySelector('a');
hijackClickEvent(projectItem, link);
});

View file

@ -1,6 +1,5 @@
import "./header.js"; import "./header.js";
import "./config.js"; import "./config.js";
import "./cursor.js";
function type_out(e) { function type_out(e) {
const text = e.innerText; const text = e.innerText;
@ -45,28 +44,12 @@ function fill_list(list) {
}); });
} }
export function hijackClickEvent(container, link) {
container.addEventListener('click', event => {
if (event.target.tagName.toLowerCase() === 'a') return;
event.preventDefault();
link.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
button: event.button,
}));
});
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
[...document.querySelectorAll(".typeout")] [...document.querySelectorAll(".typeout")]
.filter((e) => e.innerText != "") .filter((e) => e.innerText != "")
.forEach((e) => { .forEach((e) => {
type_out(e); type_out(e);
console.log(e);
}); });
[...document.querySelectorAll("ol, ul")] [...document.querySelectorAll("ol, ul")]
.filter((e) => e.innerText != "") .filter((e) => e.innerText != "")

View file

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

View file

@ -1,74 +1,66 @@
// /**
// pride flag - copyright (c) 2024 ari melody * 🏳🌈🏳💖 pride flag 💖🏳🏳🌈
// * made with by ari melody, 2023
// this code is provided AS-IS, WITHOUT ANY WARRANTY, to be *
// freely redistributed and/or modified as you please, however * web: https://arimelody.me
// retaining this license in any redistribution. * source: https://github.com/mellodoot/prideflag
// */
// 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 = const pride_flag_svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120"> `<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="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="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="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="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="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"/> <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="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/> <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="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/> <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="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/> <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"/> <circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>`; </svg>`;
const pride_flag_css = const pride_flag_css =
`#prideflag { `#pride-flag svg {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
width: 120px; width: 120px;
transform-origin: 100% 0%; transform-origin: 100% 0%;
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01); transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
z-index: 8008135; z-index: 8008135;
pointer-events: none; pointer-events: none;
} }
#prideflag:hover { #pride-flag svg:hover {
transform: scale(110%); transform: scale(110%);
} }
#prideflag:active { #pride-flag svg:active {
transform: scale(110%); transform: scale(110%);
} }
#prideflag * { #pride-flag svg * {
pointer-events: all; pointer-events: all;
}`; }`;
function create_pride_flag() { function create_pride_flag() {
const flag = document.createElement("a"); const container = document.createElement("a");
flag.id = "prideflag"; container.id = "pride-flag";
flag.href = pride_url; container.href = "https://github.com/mellodoot/prideflag";
flag.target = "_blank"; container.target = "_blank";
flag.innerHTML = pride_flag_svg; container.innerHTML = pride_flag_svg;
return flag; return container;
} }
function load_pride_flag_style() { function load_pride_flag_style() {
const pride_stylesheet = document.createElement('style'); const pride_stylesheet = document.createElement('style');
pride_stylesheet.textContent = pride_flag_css; pride_stylesheet.textContent = pride_flag_css;
document.head.appendChild(pride_stylesheet); document.head.appendChild(pride_stylesheet);
} }
load_pride_flag_style(); load_pride_flag_style();
flag = create_pride_flag(); pride_flag = create_pride_flag();
document.body.appendChild(flag); document.querySelector("main").appendChild(pride_flag);

View file

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

View file

@ -1,9 +0,0 @@
canvas#cursors {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 100;
}

View file

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

View file

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

View file

@ -1,43 +1,43 @@
main { main {
width: min(calc(100% - 4rem), 720px); width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem); min-height: calc(100vh - 10.3rem);
margin: 0 auto 2rem auto; margin: 0 auto 2rem auto;
padding-top: 4rem; padding-top: 4rem;
} }
main h1 { main h1 {
line-height: 3rem; line-height: 3rem;
color: var(--primary); color: var(--primary);
} }
main h2 { main h2 {
color: var(--secondary); color: var(--secondary);
} }
main h3 { main h3 {
color: var(--tertiary); color: var(--tertiary);
} }
div#me_irl { div#me_irl {
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
border: 2px solid white; border: 2px solid white;
} }
div#me_irl img { div#me_irl img {
display: block; display: block;
} }
div#me_irl::before { div#me_irl::before {
content: ""; content: "";
position: absolute; position: absolute;
width: 104px; width: 104px;
height: 104px; height: 104px;
transform: translate(2px, 2px); transform: translate(2px, 2px);
background-image: linear-gradient(to top right, background-image: linear-gradient(to top right,
var(--primary), var(--primary),
var(--secondary)); var(--secondary));
z-index: -1; z-index: -1;
} }
h1, h1,
@ -49,7 +49,7 @@ h6,
p, p,
small, small,
blockquote { blockquote {
transition: background-color 0.1s; transition: background-color 0.1s;
} }
h1 a, h1 a,
@ -58,7 +58,7 @@ h3 a,
h4 a, h4 a,
h5 a, h5 a,
h6 a { h6 a {
color: inherit; color: inherit;
} }
h1 a:hover, h1 a:hover,
@ -67,7 +67,7 @@ h3 a:hover,
h4 a:hover, h4 a:hover,
h5 a:hover, h5 a:hover,
h6 a:hover { h6 a:hover {
text-decoration: none; text-decoration: none;
} }
main h1:hover, main h1:hover,
@ -79,256 +79,72 @@ main h6:hover,
main p:hover, main p:hover,
main small:hover, main small:hover,
main blockquote:hover { main blockquote:hover {
background-color: #fff1; background-color: #fff1;
} }
blockquote { blockquote {
margin: 1rem 0; margin: 1rem 0;
padding: 0 2.5rem; padding: 0 2.5rem;
} }
hr { hr {
text-align: center; text-align: center;
line-height: 0px; line-height: 0px;
border-width: 1px 0 0 0; border-width: 1px 0 0 0;
border-color: #888; border-color: #888f;
margin: 1.5em 0; margin: 1.5em 0;
overflow: visible; overflow: visible;
} }
ul.platform-links { ul.links {
padding-left: 1em; display: flex;
display: flex; gap: 1em .5em;
gap: .5em; flex-wrap: wrap;
flex-wrap: wrap;
} }
ul.platform-links li { ul.links li {
list-style: none; list-style: none;
} }
ul.platform-links li a { ul.links li a {
padding: .4em .5em; padding: .4em .5em;
display: flex; border: 1px solid var(--links);
flex-direction: row; color: var(--links);
justify-content: center; border-radius: 2px;
align-items: center; background-color: transparent;
gap: .5em; transition-property: color, border-color, background-color;
border: 1px solid var(--links); transition-duration: .2s;
color: var(--links); animation-delay: 0s;
border-radius: 2px; animation: list-item-fadein .2s forwards;
background-color: transparent; opacity: 0;
transition-property: color, border-color, background-color, box-shadow;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
} }
ul.platform-links li a:hover { ul.links li a:hover {
color: #eee; color: #eee;
border-color: #eee; border-color: #eee;
background-color: var(--links) !important; background-color: var(--links) !important;
text-decoration: none; text-decoration: none;
box-shadow: 0 0 1em var(--links); box-shadow: 0 0 1em var(--links);
}
ul.platform-links li a img {
height: 1em;
width: 1em;
}
ul#projects {
padding: 0;
list-style: none;
}
li.project-item {
padding: .5em;
border: 1px solid var(--links);
margin: 1em 0;
display: flex;
flex-direction: row;
gap: .5em;
border-radius: 2px;
transition-property: color, border-color, background-color, box-shadow;
transition-duration: .2s;
cursor: pointer;
}
li.project-item a {
transition: color .2s linear;
}
li.project-item:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
box-shadow: 0 0 1em var(--links);
}
li.project-item:hover a {
color: #eee;
}
li.project-item .project-info {
display: flex;
flex-direction: column;
justify-content: center;
}
li.project-item img.project-icon {
width: 2.5em;
height: 2.5em;
object-fit: cover;
border-radius: 2px;
}
li.project-item span.project-icon {
font-size: 2em;
display: block;
width: 45px;
height: 45px;
text-align: center;
/* background: #0004; */
/* border: 1px solid var(--on-background); */
border-radius: 2px;
}
li.project-item a {
text-decoration: none;
}
li.project-item p {
margin: 0;
} }
div#web-buttons { div#web-buttons {
margin: 2rem 0; margin: 2rem 0;
} }
#web-buttons a { #web-buttons a {
text-decoration: none; text-decoration: none;
} }
#web-buttons img { #web-buttons img {
image-rendering: auto; image-rendering: auto;
image-rendering: crisp-edges; image-rendering: crisp-edges;
image-rendering: pixelated; image-rendering: pixelated;
} }
#web-buttons img:hover { #web-buttons img:hover {
margin: -1px; margin: -1px;
border: 1px solid #eee; border: 1px solid #eee;
transform: translate(-2px, -2px); transform: translate(-2px, -2px);
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee; box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
} }
#live-banner {
margin: 1em 0 2em 0;
padding: 1em;
border-radius: 4px;
border: 1px solid var(--primary);
box-shadow: 0 0 8px var(--primary);
}
#live-banner p {
margin: 0;
}
.live-highlight {
color: var(--primary);
}
.live-preview {
display: flex;
flex-direction: row;
justify-content: start;
gap: 1em;
}
.live-preview div:first-of-type {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: .3em;
}
.live-thumbnail {
border-radius: 4px;
}
.live-button {
margin: .2em;
padding: .4em .5em;
display: inline-block;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 4px;
transition: color .1s linear, background-color .1s linear, box-shadow .1s linear;
}
.live-button:hover {
color: var(--background);
background-color: var(--primary);
box-shadow: 0 0 8px var(--primary);
text-decoration: none;
}
.live-info {
display: flex;
flex-direction: column;
gap: .3em;
overflow-x: hidden;
}
#live-banner h2 {
margin: 0;
color: var(--on-background);
font-family: 'Inter', sans-serif;
font-weight: 800;
font-style: italic;
}
.live-pinger {
width: .5em;
height: .5em;
margin: .1em .2em;
display: inline-block;
border-radius: 100%;
background-color: var(--primary);
box-shadow: 0 0 4px var(--primary);
animation: live-pinger-pulse 1s infinite alternate ease-in-out;
}
@keyframes live-pinger-pulse {
from {
opacity: .8;
transform: scale(1.0);
}
to {
opacity: 1;
transform: scale(1.1);
}
}
.live-game {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
}
.live-game .live-game-prefix {
opacity: .8;
}
.live-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.live-viewers {
opacity: .5;
}

View file

@ -2,8 +2,6 @@
@import url("/style/header.css"); @import url("/style/header.css");
@import url("/style/footer.css"); @import url("/style/footer.css");
@import url("/style/prideflag.css"); @import url("/style/prideflag.css");
@import url("/style/cursor.css");
@import url("/font/inter/inter.css");
@font-face { @font-face {
font-family: "Monaspace Argon"; font-family: "Monaspace Argon";
@ -16,17 +14,15 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: var(--background); background: #080808;
color: var(--on-background); color: #eee;
font-family: "Monaspace Argon", monospace; font-family: "Monaspace Argon", monospace;
font-size: 18px; font-size: 18px;
text-shadow: 0 0 3em;
scroll-behavior: smooth; scroll-behavior: smooth;
transition: color .2s, background-color .2s;
} }
body.crt #overlay { main {
display: block;
} }
a { a {
@ -122,7 +118,6 @@ a#backtotop:hover {
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; 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-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-size: 100vw .2em;
background-repeat: repeat; background-repeat: repeat;
@ -141,27 +136,3 @@ 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,10 +17,6 @@ body {
font-family: "Monaspace Argon", monospace; font-family: "Monaspace Argon", monospace;
} }
header {
background-color: #111;
}
#background { #background {
position: fixed; position: fixed;
top: 0; top: 0;
@ -262,7 +258,7 @@ div#info p {
#title, #title,
#artist { #artist {
text-shadow: 0 .05em 2px #0004; text-shadow: 0 .05em 2px #0004
} }
#type { #type {
@ -613,10 +609,6 @@ footer a:hover {
margin: 0 auto; margin: 0 auto;
} }
#tracks h2 {
margin: 0 auto .8em auto;
}
#lyrics p.album-track-subheading { #lyrics p.album-track-subheading {
margin-bottom: 1em; margin-bottom: 1em;
} }

View file

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

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