Compare commits

..

No commits in common. "rewrite/service-repo" and "main" have entirely different histories.

94 changed files with 1835 additions and 4432 deletions

View file

@ -9,13 +9,13 @@ import (
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/model/app"
"golang.org/x/crypto/bcrypt"
)
func accountHandler(app *app.AppState) http.Handler {
func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/account/totp-setup", totpSetupHandler(app))
@ -28,7 +28,7 @@ func accountHandler(app *app.AppState) http.Handler {
return mux
}
func accountIndexHandler(app *app.AppState) http.Handler {
func accountIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -76,7 +76,7 @@ func accountIndexHandler(app *app.AppState) http.Handler {
})
}
func changePasswordHandler(app *app.AppState) http.Handler {
func changePasswordHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
@ -107,7 +107,8 @@ func changePasswordHandler(app *app.AppState) http.Handler {
return
}
err = app.AccountService.ChangePassword(session.Account.ID, string(hashedPassword))
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.")
@ -115,7 +116,7 @@ func changePasswordHandler(app *app.AppState) http.Handler {
return
}
app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
@ -123,7 +124,7 @@ func changePasswordHandler(app *app.AppState) http.Handler {
})
}
func deleteAccountHandler(app *app.AppState) http.Handler {
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)
@ -145,13 +146,13 @@ func deleteAccountHandler(app *app.AppState) http.Handler {
// check password
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
app.LogService.Warn(model.LOG_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
err = app.AccountService.Delete(session.Account.ID)
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.")
@ -159,7 +160,7 @@ func deleteAccountHandler(app *app.AppState) http.Handler {
return
}
app.LogService.Info(model.LOG_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionAccount(app.DB, session, nil)
controller.SetSessionError(app.DB, session, "")
@ -175,7 +176,7 @@ type totpConfirmData struct {
QRBase64Image string
}
func totpSetupHandler(app *app.AppState) http.Handler {
func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
session := r.Context().Value("session").(*model.Session)
@ -246,7 +247,7 @@ func totpSetupHandler(app *app.AppState) http.Handler {
})
}
func totpConfirmHandler(app *app.AppState) http.Handler {
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)
@ -310,7 +311,7 @@ func totpConfirmHandler(app *app.AppState) http.Handler {
return
}
app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
@ -318,7 +319,7 @@ func totpConfirmHandler(app *app.AppState) http.Handler {
})
}
func totpDeleteHandler(app *app.AppState) http.Handler {
func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
@ -358,7 +359,7 @@ func totpDeleteHandler(app *app.AppState) http.Handler {
return
}
app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))

View file

@ -6,11 +6,11 @@ import (
"strings"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model/app"
)
func serveArtists(app *app.AppState) http.Handler {
func serveArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -22,7 +22,7 @@ func serveArtists(app *app.AppState) http.Handler {
return
}
artists, err := app.MusicService.GetAllArtists()
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -45,11 +45,11 @@ func serveArtists(app *app.AppState) http.Handler {
})
}
func serveArtist(app *app.AppState, artistID string) http.Handler {
func serveArtist(app *model.AppState, artistID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
artist, err := app.MusicService.GetArtistByID(artistID)
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
if artist == nil {
http.NotFound(w, r)
@ -60,7 +60,7 @@ func serveArtist(app *app.AppState, artistID string) http.Handler {
return
}
credits, err := app.MusicService.GetArtistCredits(artistID, true)
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil {
fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -2,6 +2,7 @@ package admin
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
@ -10,8 +11,8 @@ import (
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/view"
"golang.org/x/crypto/bcrypt"
@ -22,7 +23,7 @@ type adminPageData struct {
Session *model.Session
}
func Handler(app *app.AppState) http.Handler {
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -74,7 +75,7 @@ func Handler(app *app.AppState) http.Handler {
return enforceSession(app, mux)
}
func AdminIndexHandler(app *app.AppState) http.Handler {
func AdminIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@ -83,39 +84,39 @@ func AdminIndexHandler(app *app.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
releases, err := app.MusicService.GetAllReleases(false, 3)
releases, err := controller.GetAllReleases(app.DB, false, 3, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releaseCount, err := app.MusicService.GetReleaseCount(false)
releaseCount, err := controller.GetReleaseCount(app.DB, false)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := app.MusicService.GetAllArtists()
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artistCount, err := app.MusicService.GetArtistCount()
artistCount, err := controller.GetArtistCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := app.MusicService.GetOrphanTracks()
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
trackCount, err := app.MusicService.GetTrackCount()
trackCount, err := controller.GetTrackCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -149,7 +150,7 @@ func AdminIndexHandler(app *app.AppState) http.Handler {
})
}
func registerAccountHandler(app *app.AppState) http.Handler {
func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -222,13 +223,13 @@ func registerAccountHandler(app *app.AppState) http.Handler {
return
}
defaultAvatar := "/img/default-avatar.png"
accountID, err := app.AccountService.Create(
credentials.Username,
string(hashedPassword),
&credentials.Email,
&defaultAvatar,
)
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.")
@ -241,36 +242,22 @@ func registerAccountHandler(app *app.AppState) http.Handler {
return
}
app.LogService.Info(
model.LOG_ACCOUNT,
"Account \"%s\" (%s) created using invite \"%s\". (%s)",
credentials.Username,
accountID,
invite.Code,
controller.ResolveIP(app, r),
)
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil {
app.LogService.Warn(model.LOG_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
}
// registration success!
if account, err := app.AccountService.GetByID(accountID); err != nil || account == nil {
controller.SetSessionError(
app.DB, session,
"Account created, but something went wrong logging you in. Please try logging in manually.",
)
} else {
controller.SetSessionAccount(app.DB, session, account)
}
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 *app.AppState) http.Handler {
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)
@ -312,7 +299,7 @@ func loginHandler(app *app.AppState) http.Handler {
username := r.FormValue("username")
password := r.FormValue("password")
account, err := app.AccountService.GetByUsername(username)
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.")
@ -332,7 +319,7 @@ func loginHandler(app *app.AppState) http.Handler {
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.LogService.Warn(model.LOG_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
@ -366,8 +353,8 @@ func loginHandler(app *app.AppState) http.Handler {
// login success!
// TODO: log login activity to user
app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
app.LogService.Warn(model.LOG_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
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 {
@ -382,7 +369,7 @@ func loginHandler(app *app.AppState) http.Handler {
})
}
func loginTOTPHandler(app *app.AppState) http.Handler {
func loginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -420,7 +407,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler {
totpCode := r.FormValue("totp")
if len(totpCode) != controller.TOTP_CODE_LENGTH {
app.LogService.Warn(model.LOG_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
@ -434,7 +421,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler {
return
}
if totpMethod == nil {
app.LogService.Warn(model.LOG_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
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)
@ -446,7 +433,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler {
return
}
app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
@ -465,7 +452,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler {
})
}
func logoutHandler(app *app.AppState) http.Handler {
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)
@ -527,7 +514,7 @@ func staticHandler() http.Handler {
}
*/
func enforceSession(app *app.AppState, next http.Handler) http.Handler {
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
@ -560,45 +547,8 @@ func enforceSession(app *app.AppState, next http.Handler) http.Handler {
})
}
// Helper for handling login failures. Increments the account auth failure
// count and logs warnings. If failure count exceeds MAX_LOGIN_FAIL_ATTEMPTS,
// the account will be locked.
func handleFailedLogin(app *app.AppState, account *model.Account, r *http.Request) bool {
failAttempts, err := app.AccountService.IncrementFails(account.ID)
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
err = app.AccountService.Lock(account.ID)
if err != nil {
fmt.Fprintf(
os.Stderr,
"WARN: Failed to lock account \"%s\": %v\n",
account.Username,
err,
)
app.LogService.Warn(
model.LOG_ACCOUNT,
"Failed to lock account \"%s\"",
account.Username,
)
}
fmt.Fprintf(
os.Stderr,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
app.LogService.Warn(
model.LOG_ACCOUNT,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
return true
}
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,
@ -606,12 +556,20 @@ func handleFailedLogin(app *app.AppState, account *model.Account, r *http.Reques
account.Username,
err,
)
app.LogService.Warn(
model.LOG_ACCOUNT,
app.Log.Warn(
log.TYPE_ACCOUNT,
"Failed to increment login failures for \"%s\"",
account.Username,
)
}
return false
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

@ -2,15 +2,15 @@ package admin
import (
"arimelody-web/admin/templates"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/model/app"
"fmt"
"net/http"
"os"
"strings"
)
func logsHandler(app *app.AppState) http.Handler {
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)
@ -19,16 +19,16 @@ func logsHandler(app *app.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
levelFilter := []model.LogLevel{}
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]model.LogLevel{
"info": model.LEVEL_INFO,
"warn": model.LEVEL_WARN,
m := map[string]log.LogLevel{
"info": log.LEVEL_INFO,
"warn": log.LEVEL_WARN,
}
level, ok := m[strings.TrimPrefix(key, "level-")]
if ok {
@ -43,7 +43,7 @@ func logsHandler(app *app.AppState) http.Handler {
}
}
logs, err := app.LogService.Search(levelFilter, typeFilter, query, 100, 0)
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)
@ -52,7 +52,7 @@ func logsHandler(app *app.AppState) http.Handler {
type LogsResponse struct {
adminPageData
Logs []*model.Log
Logs []*log.Log
}
err = templates.LogsTemplate.Execute(w, LogsResponse{

View file

@ -7,12 +7,11 @@ import (
"strings"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/errors"
)
func serveReleases(app *app.AppState) http.Handler {
func serveReleases(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -34,7 +33,7 @@ func serveReleases(app *app.AppState) http.Handler {
Releases []*model.Release
}
releases, err := app.MusicService.GetAllFullReleases(false, 0)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -56,17 +55,13 @@ func serveReleases(app *app.AppState) http.Handler {
})
}
func serveRelease(app *app.AppState, releaseID string, action string) http.Handler {
func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
release, err := app.MusicService.GetFullReleaseByID(releaseID)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -108,7 +103,9 @@ func serveRelease(app *app.AppState, releaseID string, action string) http.Handl
Release *model.Release
}
for i, track := range release.Tracks { track.Number = i + 1 }
for i, track := range release.Tracks {
track.Number = i + 1
}
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
@ -132,9 +129,9 @@ func serveEditCredits(release *model.Release) http.Handler {
})
}
func serveAddCredit(app *app.AppState, release *model.Release) http.Handler {
func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := app.MusicService.GetArtistsNotOnRelease(release.ID)
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -158,11 +155,10 @@ func serveAddCredit(app *app.AppState, release *model.Release) http.Handler {
})
}
func serveNewCredit(app *app.AppState) http.Handler {
func serveNewCredit(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
split := strings.Split(r.URL.Path, "/")
artistID := split[len(split) - 1]
artist, err := app.MusicService.GetArtistByID(artistID)
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -199,8 +195,6 @@ func serveEditTracks(release *model.Release) http.Handler {
type editTracksData struct { Release *model.Release }
for i, track := range release.Tracks { track.Number = i + 1 }
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
if err != nil {
fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
@ -209,9 +203,9 @@ func serveEditTracks(release *model.Release) http.Handler {
})
}
func serveAddTrack(app *app.AppState, release *model.Release) http.Handler {
func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := app.MusicService.GetTracksNotOnRelease(release.ID)
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -235,11 +229,10 @@ func serveAddTrack(app *app.AppState, release *model.Release) http.Handler {
})
}
func serveNewTrack(app *app.AppState) http.Handler {
func serveNewTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
split := strings.Split(r.URL.Path, "/")
trackID := split[len(split) - 1]
track, err := app.MusicService.GetTrackByID(trackID)
trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -111,7 +111,7 @@ body {
font-family: "Inter", sans-serif;
font-size: 16px;
color: var(--fg-0);
background-color: var(--bg-0);
background: var(--bg-0);
transition: background .1s ease-out, color .1s ease-out;
}
@ -252,6 +252,12 @@ a {
transition: color .1s ease-out, background-color .1s ease-out;
}
/*
a:hover {
text-decoration: underline;
}
*/
img.icon {
height: .8em;
transition: filter .1s ease-out;
@ -277,7 +283,7 @@ code {
.card {
flex-basis: 40em;
padding: 1em;
background-color: var(--bg-1);
background: var(--bg-1);
border-radius: 16px;
box-shadow: var(--shadow-lg);
@ -355,7 +361,7 @@ a.delete:not(.button) {
font-size: inherit;
color: inherit;
background-color: var(--bg-2);
background: var(--bg-2);
border: none;
border-radius: 10em;
box-shadow: var(--shadow-sm);
@ -374,27 +380,27 @@ button:active, .button:active {
.button.new, button.new {
color: var(--col-on-new);
background-color: var(--col-new);
background: var(--col-new);
}
.button.save, button.save {
color: var(--col-on-save);
background-color: var(--col-save);
background: var(--col-save);
}
.button.delete, button.delete {
color: var(--col-on-delete);
background-color: var(--col-delete);
background: var(--col-delete);
}
.button:hover, button:hover {
color: var(--bg-3);
background-color: var(--fg-3);
background: var(--fg-3);
}
.button:active, button:active {
color: var(--bg-2);
background-color: var(--fg-0);
background: var(--fg-0);
}
.button[disabled], button[disabled] {
color: var(--fg-0) !important;
background-color: var(--bg-3) !important;
background: var(--bg-3) !important;
opacity: .5;
cursor: default !important;
}

View file

@ -2,7 +2,7 @@
padding: .5em;
color: var(--fg-3);
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
text-align: center;
@ -12,7 +12,7 @@
}
.artist:hover {
background-color: var(--bg-1);
background: var(--bg-1);
text-decoration: hover;
}

View file

@ -4,29 +4,4 @@ document.addEventListener("readystatechange", () => {
document.querySelectorAll(".artists-group .artist").forEach(el => {
hijackClickEvent(el, el.querySelector("a.artist-name"))
});
const newArtistBtn = document.getElementById("create-artist");
if (newArtistBtn) newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artists/" + id;
} else {
alert(text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
});

View file

@ -33,7 +33,7 @@ form#delete-account input {
justify-content: space-between;
color: var(--fg-3);
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
}

View file

@ -6,7 +6,7 @@
gap: 1.2em;
border-radius: 16px;
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -50,11 +50,18 @@ input[type="text"] {
font-family: inherit;
font-weight: inherit;
color: inherit;
background-color: var(--bg-0);
background: var(--bg-0);
border: none;
border-radius: 4px;
outline: none;
}
input[type="text"]:hover {
border-color: #80808080;
}
input[type="text"]:active,
input[type="text"]:focus {
border-color: #808080;
}
.artist-actions {
margin-top: auto;
@ -77,7 +84,7 @@ input[type="text"] {
align-items: center;
border-radius: 16px;
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
cursor: pointer;
@ -85,7 +92,7 @@ input[type="text"] {
}
.credit:hover {
background-color: var(--bg-1);
background: var(--bg-1);
}
.release-artwork {

View file

@ -12,7 +12,7 @@ input[type="text"] {
gap: 1.2em;
border-radius: 8px;
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@ -33,7 +33,7 @@ input[type="text"] {
.release-artwork #remove-artwork {
margin-top: .5em;
padding: .3em .6em;
background-color: var(--bg-3);
background: var(--bg-3);
}
.release-info {
@ -62,13 +62,13 @@ input[type="text"] {
}
#title:hover {
background-color: var(--bg-3);
background: var(--bg-3);
border-color: var(--fg-0);
}
#title:active,
#title:focus {
background-color: var(--bg-3);
background: var(--bg-3);
}
.release-title small {
@ -93,7 +93,7 @@ input[type="text"] {
.release-info table tr td:not(:first-child) select:hover,
.release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover {
background-color: var(--bg-3);
background: var(--bg-3);
cursor: pointer;
}
.release-info table td select,
@ -127,7 +127,7 @@ input[type="text"] {
.release-actions button,
.release-actions .button {
color: var(--fg-2);
background-color: var(--bg-3);
background: var(--bg-3);
}
dialog {
@ -234,7 +234,7 @@ dialog div.dialog-actions {
gap: 1em;
border-radius: 8px;
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -280,7 +280,7 @@ dialog div.dialog-actions {
border: none;
border-radius: 4px;
color: var(--fg-2);
background-color: var(--bg-0);
background: var(--bg-0);
}
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
@ -299,7 +299,6 @@ dialog div.dialog-actions {
#editcredits .credit .delete {
margin-right: .5em;
cursor: pointer;
overflow: visible;
}
#editcredits .credit .delete:hover {
text-decoration: underline;
@ -316,17 +315,14 @@ dialog div.dialog-actions {
display: flex;
gap: .5em;
cursor: pointer;
background-color: var(--bg-2);
}
#addcredit ul li.new-artist:nth-child(even) {
background: #f0f0f0;
background-color: var(--bg-1);
}
#addcredit ul li.new-artist:hover {
background: #e0e0e0;
background-color: var(--bg-2);
}
#addcredit .new-artist .artist-id {
@ -379,8 +375,6 @@ dialog div.dialog-actions {
#editlinks tr {
display: flex;
background-color: var(--bg-1);
transition: background-color .1s ease-out;
}
#editlinks th {
@ -391,7 +385,7 @@ dialog div.dialog-actions {
}
#editlinks tr:nth-child(odd) {
background-color: var(--bg-2);
background: #f8f8f8;
}
#editlinks tr th,
@ -422,11 +416,6 @@ dialog div.dialog-actions {
width: 1em;
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
#editlinks tr .grabber img {
filter: invert();
}
}
#editlinks tr .link-name {
width: 8em;
}
@ -465,7 +454,6 @@ dialog div.dialog-actions {
}
#edittracks .track {
background-color: var(--bg-2);
transition: transform .2s ease-out, opacity .2s;
}
@ -488,7 +476,7 @@ dialog div.dialog-actions {
}
#edittracks .track:nth-child(even) {
background-color: var(--bg-1);
background: #f0f0f0;
}
#edittracks .track-number {
@ -504,6 +492,7 @@ dialog div.dialog-actions {
#addtrack ul {
padding: 0;
list-style: none;
background: #f8f8f8;
}
#addtrack ul li.new-track {

View file

@ -8,7 +8,7 @@
gap: 1.2em;
border-radius: 16px;
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -45,13 +45,25 @@
font-weight: inherit;
font-family: inherit;
font-size: inherit;
background-color: var(--bg-0);
background: var(--bg-0);
border: none;
border-radius: 4px;
outline: none;
color: inherit;
}
.track-info input[type="text"]:hover,
.track-info textarea:hover {
border-color: #80808080;
}
.track-info input[type="text"]:active,
.track-info textarea:active,
.track-info input[type="text"]:focus,
.track-info textarea:focus {
border-color: #808080;
}
.track-actions {
margin-top: 1em;
display: flex;

74
admin/static/index.js Normal file
View file

@ -0,0 +1,74 @@
const newReleaseBtn = document.getElementById("create-release");
const newArtistBtn = document.getElementById("create-artist");
const newTrackBtn = document.getElementById("create-track");
newReleaseBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this release:");
if (id == null || id == "") return;
fetch("/api/v1/music", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/releases/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});
newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artists/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
newTrackBtn.addEventListener("click", event => {
event.preventDefault();
const title = prompt("Enter an title for this track:");
if (title == null || title == "") return;
fetch("/api/v1/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({title})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/tracks/" + text;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create track. Check the console for details.");
console.error(err);
});
});

View file

@ -8,7 +8,7 @@ form#search-form {
padding: 1em;
border-radius: 16px;
color: var(--fg-0);
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -23,7 +23,7 @@ div#search {
border: none;
border-radius: 16px;
color: var(--fg-1);
background-color: var(--bg-0);
background: var(--bg-0);
box-shadow: var(--shadow-sm);
}
@ -100,8 +100,8 @@ td.log-content {
#logs .log.warn {
color: var(--col-on-warn);
background-color: var(--col-warn);
background: var(--col-warn);
}
#logs .log.warn:hover {
background-color: var(--col-warn-hover);
background: var(--col-warn-hover);
}

View file

@ -6,7 +6,7 @@
gap: 1em;
border-radius: 16px;
background-color: var(--bg-2);
background: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@ -67,14 +67,14 @@
display: inline-block;
border-radius: 4px;
background-color: var(--bg-3);
background: var(--bg-3);
box-shadow: var(--shadow-sm);
transition: color .1s ease-out, background .1s ease-out;
}
.release .release-actions a:hover {
background-color: var(--bg-0);
background: var(--bg-0);
color: var(--fg-3);
text-decoration: none;
}

View file

@ -1,25 +0,0 @@
document.addEventListener('readystatechange', () => {
const newReleaseBtn = document.getElementById("create-release");
if (newReleaseBtn) newReleaseBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this release:");
if (id == null || id == "") return;
fetch("/api/v1/music", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/releases/" + id;
else {
res.text().then(err => {
alert(err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});
});

View file

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

View file

@ -1,24 +0,0 @@
const newTrackBtn = document.getElementById("create-track");
if (newTrackBtn) newTrackBtn.addEventListener("click", event => {
event.preventDefault();
const title = prompt("Enter an title for this track:");
if (title == null || title == "") return;
fetch("/api/v1/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({title})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/tracks/" + text;
} else {
alert(text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create track. Check the console for details.");
console.error(err);
});
});

View file

@ -12,12 +12,12 @@
<form action="/api/v1/music/{{.Release.ID}}/tracks">
<ul>
{{range .Release.Tracks}}
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true">
{{range $i, $track := .Release.Tracks}}
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
<div>
<p class="track-name">
<span class="track-number">{{.Number}}</span>
{{.Title}}
<span class="track-number">{{.Add $i 1}}</span>
{{$track.Title}}
</p>
<a class="delete">Delete</a>
</div>
@ -49,6 +49,7 @@
deleteBtn.addEventListener("click", e => {
e.preventDefault();
if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return;
trackItem.remove();
refreshTrackNumbers();
});

View file

@ -100,7 +100,7 @@
</div>
</div>
<div id="credits" class="card">
<div class="card" id="credits">
<div class="card-header">
<h2>Credits <small>({{len .Release.Credits}} total)</small></h2>
<a class="button edit"
@ -110,7 +110,6 @@
hx-swap="beforeend"
>Edit</a>
</div>
{{range .Release.Credits}}
<div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
@ -126,13 +125,13 @@
</div>
{{end}}
{{if not .Release.Credits}}
<p>This release has no credits.</p>
<p>There are no credits.</p>
{{end}}
</div>
<div id="links" class="card">
<div class="card" id="links">
<div class="card-header">
<h2>Links <small>({{len .Release.Links}} total)</small></h2>
<h2>Links ({{len .Release.Links}})</h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/editlinks"
hx-get="/admin/releases/{{.Release.ID}}/editlinks"
@ -140,21 +139,16 @@
hx-swap="beforeend"
>Edit</a>
</div>
{{if .Release.Links}}
<ul>
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</ul>
{{else}}
<p>This release has no links.</p>
{{end}}
</div>
<div id="tracks" class="card">
<div class="card-header">
<h2>Tracks <small>({{len .Release.Tracks}} total)</small></h2>
<div class="card" id="tracks">
<div class="card-header" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/edittracks"
hx-get="/admin/releases/{{.Release.ID}}/edittracks"
@ -162,13 +156,9 @@
hx-swap="beforeend"
>Edit</a>
</div>
{{range .Release.Tracks}}
{{range $i, $track := .Release.Tracks}}
{{block "track" .}}{{end}}
{{end}}
{{if not .Release.Tracks}}
<p>This release has no tracks.</p>
{{end}}
</div>
<div class="card" id="danger">

View file

@ -56,7 +56,6 @@
</main>
<script type="module" src="/admin/static/releases.js"></script>
<script type="module" src="/admin/static/artists.js"></script>
<script type="module" src="/admin/static/tracks.js"></script>
<script type="module" src="/admin/static/index.js"></script>
{{end}}

View file

@ -21,6 +21,4 @@
<p>There are no releases.</p>
{{end}}
</main>
<script type="module" src="/admin/static/releases.js"></script>
{{end}}

View file

@ -12,8 +12,22 @@
</header>
<div id="tracks">
{{range .Tracks}}
{{block "track" .}}{{end}}
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/tracks/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
</div>
</main>

View file

@ -1,7 +1,7 @@
package templates
import (
"arimelody-web/model"
"arimelody-web/log"
_ "embed"
"fmt"
"html/template"
@ -163,11 +163,11 @@ var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(c
func parseLevel(level model.LogLevel) string {
func parseLevel(level log.LogLevel) string {
switch level {
case model.LEVEL_INFO:
case log.LEVEL_INFO:
return "INFO"
case model.LEVEL_WARN:
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)

View file

@ -6,11 +6,11 @@ import (
"strings"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model/app"
)
func serveTracks(app *app.AppState) http.Handler {
func serveTracks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -22,7 +22,7 @@ func serveTracks(app *app.AppState) http.Handler {
return
}
tracks, err := app.MusicService.GetAllTracks()
tracks, err := controller.GetAllTracks(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -45,11 +45,11 @@ func serveTracks(app *app.AppState) http.Handler {
})
}
func serveTrack(app *app.AppState, trackID string) http.Handler {
func serveTrack(app *model.AppState, trackID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
track, err := app.MusicService.GetTrackByID(trackID)
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -60,7 +60,7 @@ func serveTrack(app *app.AppState, trackID string) http.Handler {
return
}
releases, err := app.MusicService.GetTrackReleases(trackID)
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil {
fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -1,19 +1,17 @@
package api
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"context"
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/errors"
"arimelody-web/controller"
"arimelody-web/model"
)
func Handler(app *app.AppState) http.Handler {
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
// TODO: generate API keys on the frontend
@ -22,9 +20,9 @@ func Handler(app *app.AppState) http.Handler {
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artistID = strings.Split(r.URL.Path[1:], "/")[0]
artist, err := app.MusicService.GetArtistByID(artistID)
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -64,9 +62,9 @@ func Handler(app *app.AppState) http.Handler {
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
release, err := app.MusicService.GetFullReleaseByID(releaseID)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -106,9 +104,9 @@ func Handler(app *app.AppState) http.Handler {
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackID = strings.Split(r.URL.Path[1:], "/")[0]
track, err := app.MusicService.GetTrackByID(trackID)
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -168,7 +166,7 @@ func requireAccount(next http.Handler) http.Handler {
})
}
func getSession(app *app.AppState, r *http.Request) (*model.Session, error) {
func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
var token string
// check cookies first
@ -186,12 +184,9 @@ func getSession(app *app.AppState, r *http.Request) (*model.Session, error) {
if token == "" { return nil, nil }
// fetch existing session
session, err := controller.GetSession(app, token)
session, err := controller.GetSession(app.DB, token)
if errors.IsValidationError(err) {
return nil, err
}
if errors.IsNotExistError(err) {
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v\n", err)
}

View file

@ -1,24 +1,24 @@
package api
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/errors"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
func ServeAllArtists(app *app.AppState) http.Handler {
func ServeAllArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artists = []*model.Artist{}
artists, err := app.MusicService.GetAllArtists()
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -35,7 +35,7 @@ func ServeAllArtists(app *app.AppState) http.Handler {
})
}
func ServeArtist(app *app.AppState, artist *model.Artist) http.Handler {
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type (
creditJSON struct {
@ -53,9 +53,9 @@ func ServeArtist(app *app.AppState, artist *model.Artist) http.Handler {
)
session := r.Context().Value("session").(*model.Session)
showHiddenReleases := session != nil && session.Account != nil
show_hidden_releases := session != nil && session.Account != nil
dbCredits, err := app.MusicService.GetArtistCredits(artist.ID, showHiddenReleases)
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
if err != nil {
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -87,44 +87,41 @@ func ServeArtist(app *app.AppState, artist *model.Artist) http.Handler {
})
}
func CreateArtist(app *app.AppState) http.Handler {
func CreateArtist(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type CreateArtistDTO struct {
ID string `json:"id"`
Name string `json:"name"`
}
dto := &CreateArtistDTO{}
err := json.NewDecoder(r.Body).Decode(dto)
var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
err = app.MusicService.CreateArtist(dto.ID, dto.Name, "", "")
if artist.ID == "" {
http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest)
return
}
if artist.Name == "" { artist.Name = artist.ID }
err = controller.CreateArtist(app.DB, &artist)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", dto.ID), http.StatusBadRequest)
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to create artist %s: %s\n", dto.ID, err)
fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.LogService.Info(model.LOG_ARTIST, "Artist \"%s\" created by \"%s\".", dto.Name, session.Account.Username)
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username)
w.WriteHeader(http.StatusCreated)
})
}
func UpdateArtist(app *app.AppState, artist *model.Artist) http.Handler {
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -159,13 +156,9 @@ func UpdateArtist(app *app.AppState, artist *model.Artist) http.Handler {
}
}
err = app.MusicService.UpdateArtist(artist)
err = controller.UpdateArtist(app.DB, artist)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -173,21 +166,17 @@ func UpdateArtist(app *app.AppState, artist *model.Artist) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
})
}
func DeleteArtist(app *app.AppState, artist *model.Artist) http.Handler {
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := app.MusicService.DeleteArtist(artist.ID)
err := controller.DeleteArtist(app.DB, artist.ID)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -195,6 +184,6 @@ func DeleteArtist(app *app.AppState, artist *model.Artist) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
})
}

View file

@ -1,22 +1,21 @@
package api
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/errors"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
func ServeRelease(app *app.AppState, release *model.Release) http.Handler {
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases
privileged := false
@ -40,50 +39,50 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler {
}
type (
TrackDTO struct {
Track struct {
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
}
CreditDTO struct {
Credit struct {
*model.Artist
Role string `json:"role"`
Primary bool `json:"primary"`
}
ReleaseDTO struct {
Release struct {
*model.Release
Tracks []TrackDTO `json:"tracks"`
Credits []CreditDTO `json:"credits"`
Tracks []Track `json:"tracks"`
Credits []Credit `json:"credits"`
Links map[string]string `json:"links"`
}
)
response := ReleaseDTO{
response := Release{
Release: release,
Tracks: []TrackDTO{},
Credits: []CreditDTO{},
Tracks: []Track{},
Credits: []Credit{},
Links: make(map[string]string),
}
if release.IsReleased() || privileged {
// get credits
credits, err := app.MusicService.GetReleaseCredits(release.ID)
credits, err := controller.GetReleaseCredits(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, credit := range credits {
artist, err := app.MusicService.GetArtistByID(credit.Artist.ID)
artist, err := controller.GetArtist(app.DB, credit.Artist.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
response.Credits = append(response.Credits, CreditDTO{
response.Credits = append(response.Credits, Credit{
Artist: artist,
Role: credit.Role,
Primary: credit.Primary,
@ -91,14 +90,14 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler {
}
// get tracks
tracks, err := app.MusicService.GetReleaseTracks(release.ID)
tracks, err := controller.GetReleaseTracks(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, track := range tracks {
response.Tracks = append(response.Tracks, TrackDTO{
response.Tracks = append(response.Tracks, Track{
Title: track.Title,
Description: track.Description,
Lyrics: track.Lyrics,
@ -106,7 +105,7 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler {
}
// get links
links, err := app.MusicService.GetReleaseLinks(release.ID)
links, err := controller.GetReleaseLinks(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -128,9 +127,9 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler {
})
}
func ServeCatalog(app *app.AppState) http.Handler {
func ServeCatalog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := app.MusicService.GetAllFullReleases(false, 0)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -189,66 +188,57 @@ func ServeCatalog(app *app.AppState) http.Handler {
})
}
func CreateRelease(app *app.AppState) http.Handler {
func CreateRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type CreateReleaseDTO struct {
ID string `json:"id"`
Title string `json:"title"`
ReleaseType string `json:"type"`
ReleaseDate time.Time `json:"release_date"`
Artwork string `json:"artwork"`
}
var dto CreateReleaseDTO
err := json.NewDecoder(r.Body).Decode(&dto)
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if dto.ReleaseType == "" { dto.ReleaseType = string(model.Single) }
if dto.ReleaseDate != time.Unix(0, 0) {
dto.ReleaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
if release.ID == "" {
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
return
}
if release.Title == "" { release.Title = release.ID }
if release.ReleaseType == "" { release.ReleaseType = model.Single }
if release.ReleaseDate != time.Unix(0, 0) {
release.ReleaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
}
if dto.Artwork == "" { dto.Artwork = model.DEFAULT_RELEASE_ARTWORK_URL }
err = app.MusicService.CreateRelease(
dto.ID,
dto.Title,
dto.ReleaseType,
dto.ReleaseDate,
dto.Artwork,
)
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
err = controller.CreateRelease(app.DB, &release)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", dto.ID), http.StatusBadRequest)
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to create release %s: %s\n", dto.ID, err)
fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" created by \"%s\".", dto.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err = encoder.Encode(dto)
err = encoder.Encode(release)
if err != nil {
fmt.Printf("WARN: Release %s created, but failed to send JSON response: %s\n", dto.ID, err)
fmt.Printf("WARN: Release %s created, but failed to send JSON response: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func UpdateRelease(app *app.AppState, release *model.Release) http.Handler {
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
@ -307,13 +297,9 @@ func UpdateRelease(app *app.AppState, release *model.Release) http.Handler {
}
}
err = app.MusicService.UpdateRelease(release)
err = controller.UpdateRelease(app.DB, release)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -321,63 +307,55 @@ func UpdateRelease(app *app.AppState, release *model.Release) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseTracks(app *app.AppState, release *model.Release) http.Handler {
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var newTrackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&newTrackIDs)
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
err = app.MusicService.UpdateReleaseTracks(release.ID, newTrackIDs)
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Release cannot have duplicate tracks", http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" tracklist updated by \"%s\".", release.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseCredits(app *app.AppState, release *model.Release) http.Handler {
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type CreditDTO struct {
type creditJSON struct {
Artist string
Role string
Primary bool
}
var dto []CreditDTO
err := json.NewDecoder(r.Body).Decode(&dto)
var data []creditJSON
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
var newCredits []*model.Credit
for _, credit := range dto {
newCredits = append(newCredits, &model.Credit{
Artist: &model.Artist{
var credits []*model.Credit
for _, credit := range data {
credits = append(credits, &model.Credit{
Artist: model.Artist{
ID: credit.Artist,
},
Role: credit.Role,
@ -385,81 +363,56 @@ func UpdateReleaseCredits(app *app.AppState, release *model.Release) http.Handle
})
}
err = app.MusicService.UpdateReleaseCredits(release.ID, newCredits)
err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
http.NotFound(w, r)
return
}
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Artists may only be credited once", http.StatusBadRequest)
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to update credits for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" credits updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseLinks(app *app.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type LinkDTO struct {
Name string `json:"name"`
URL string `json:"url"`
}
var dto = []LinkDTO{}
err := json.NewDecoder(r.Body).Decode(&dto)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
links := []*model.Link{}
for _, link := range dto {
links = append(links, &model.Link{ Name: link.Name, URL: link.URL })
}
err = app.MusicService.UpdateReleaseLinks(release.ID, links)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.IsNotExistError(err) {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Release cannot have duplicate link names", http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" links updated by \"%s\".", release.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func DeleteRelease(app *app.AppState, release *model.Release) http.Handler {
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := app.MusicService.DeleteRelease(release.ID)
var links = []*model.Link{}
err := json.NewDecoder(r.Body).Decode(&links)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
if errors.IsNotExistError(err) {
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteRelease(app.DB, release.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
@ -467,6 +420,6 @@ func DeleteRelease(app *app.AppState, release *model.Release) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
})
}

View file

@ -1,13 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"encoding/json"
"fmt"
"net/http"
"arimelody-web/errors"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
type (
@ -17,7 +17,7 @@ type (
}
)
func ServeAllTracks(app *app.AppState) http.Handler {
func ServeAllTracks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type Track struct {
ID string `json:"id"`
@ -26,7 +26,7 @@ func ServeAllTracks(app *app.AppState) http.Handler {
var tracks = []Track{}
var dbTracks = []*model.Track{}
dbTracks, err := app.MusicService.GetAllTracks()
dbTracks, err := controller.GetAllTracks(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -50,9 +50,9 @@ func ServeAllTracks(app *app.AppState) http.Handler {
})
}
func ServeTrack(app *app.AppState, track *model.Track) http.Handler {
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := app.MusicService.GetTrackReleases(track.ID)
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -74,34 +74,30 @@ func ServeTrack(app *app.AppState, track *model.Track) http.Handler {
})
}
func CreateTrack(app *app.AppState) http.Handler {
func CreateTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
type CreateTrackDTO struct {
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
}
var dto CreateTrackDTO
err := json.NewDecoder(r.Body).Decode(&dto)
var track model.Track
err := json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
id, err := app.MusicService.CreateTrack(dto.Title, dto.Description, dto.Lyrics, "")
if track.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
return
}
id, err := controller.CreateTrack(app.DB, &track)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.LogService.Info(model.LOG_MUSIC, "Track \"%s\" (%s) created by \"%s\".", dto.Title, id, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusCreated)
@ -109,7 +105,7 @@ func CreateTrack(app *app.AppState) http.Handler {
})
}
func UpdateTrack(app *app.AppState, track *model.Track) http.Handler {
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.NotFound(w, r)
@ -118,34 +114,25 @@ func UpdateTrack(app *app.AppState, track *model.Track) http.Handler {
session := r.Context().Value("session").(*model.Session)
type UpdateTrackDTO struct {
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
}
var dto UpdateTrackDTO
err := json.NewDecoder(r.Body).Decode(&dto)
err := json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
track.Title = dto.Title
track.Description = dto.Description
track.Lyrics = dto.Lyrics
if track.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
return
}
err = app.MusicService.UpdateTrack(track)
err = controller.UpdateTrack(app.DB, track)
if err != nil {
if errors.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.LogService.Info(model.LOG_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
@ -157,7 +144,7 @@ func UpdateTrack(app *app.AppState, track *model.Track) http.Handler {
})
}
func DeleteTrack(app *app.AppState, track *model.Track) http.Handler {
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.NotFound(w, r)
@ -167,16 +154,12 @@ func DeleteTrack(app *app.AppState, track *model.Track) http.Handler {
session := r.Context().Value("session").(*model.Session)
var trackID = r.URL.Path[1:]
err := app.MusicService.DeleteTrack(trackID)
err := controller.DeleteTrack(app.DB, trackID)
if err != nil {
if errors.IsNotExistError(err) {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.LogService.Info(model.LOG_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
})
}

View file

@ -1,18 +1,18 @@
package api
import (
"arimelody-web/model"
"arimelody-web/model/app"
"bufio"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"arimelody-web/log"
"arimelody-web/model"
"bufio"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
func HandleImageUpload(app *app.AppState, data *string, directory string, filename string) (string, error) {
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
split := strings.Split(*data, ";base64,")
header := split[0]
imageData, err := base64.StdEncoding.DecodeString(split[1])
@ -50,7 +50,7 @@ func HandleImageUpload(app *app.AppState, data *string, directory string, filena
return "", nil
}
app.LogService.Info(model.LOG_FILES, "\"%s\" created.", imagePath)
app.Log.Info(log.TYPE_FILES, "\"%s\" created.", imagePath)
return filename, nil
}

135
controller/account.go Normal file
View file

@ -0,0 +1,135 @@
package controller
import (
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
)
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
var accounts = []model.Account{}
err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
if err != nil {
return nil, err
}
return accounts, nil
}
func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) {
if sessionToken == "" { return nil, nil }
account := model.Account{}
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func CreateAccount(db *sqlx.DB, account *model.Account) error {
err := db.Get(
&account.ID,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
return err
}
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec(
"UPDATE account " +
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"WHERE id=$1",
account.ID,
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
return err
}
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
failAttempts := 0
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
if err != nil { return false, err }
locked := false
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
err = LockAccount(db, accountID)
if err != nil { return false, err }
locked = true
}
return locked, err
}
func LockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
return err
}
func UnlockAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
return err
}

View file

@ -1,29 +1,17 @@
package music
package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
func (repo *MusicRepositoryPostgres) GetAllArtists() ([]*model.Artist, error) {
var artists = []*model.Artist{}
// DATABASE
err := repo.db.Select(&artists, "SELECT * FROM artist")
if err != nil {
return nil, err
}
return artists, nil
}
func (repo *MusicRepositoryPostgres) GetArtistCount() (int, error) {
var count int
err := repo.db.Get(&count, "SELECT count(*) FROM artist")
return count, err
}
func (repo *MusicRepositoryPostgres) GetArtistByID(id string) (*model.Artist, error) {
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
var artist = model.Artist{}
err := repo.db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
if err != nil {
return nil, err
}
@ -31,10 +19,26 @@ func (repo *MusicRepositoryPostgres) GetArtistByID(id string) (*model.Artist, er
return &artist, nil
}
func (repo *MusicRepositoryPostgres) GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) {
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
var artists = []*model.Artist{}
err := repo.db.Select(&artists,
err := db.Select(&artists, "SELECT * FROM artist")
if err != nil {
return nil, err
}
return artists, nil
}
func GetArtistCount(db *sqlx.DB) (int, error) {
var count int
err := db.Get(&count, "SELECT count(*) FROM artist")
return count, err
}
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
var artists = []*model.Artist{}
err := db.Select(&artists,
"SELECT * FROM artist "+
"WHERE id NOT IN "+
"(SELECT artist FROM musiccredit WHERE release=$1)",
@ -46,15 +50,15 @@ func (repo *MusicRepositoryPostgres) GetArtistsNotOnRelease(releaseID string) ([
return artists, nil
}
func (repo *MusicRepositoryPostgres) GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) {
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
var query string = "SELECT release.id,title,artwork,release_date,artist.id,name,website,avatar,role,is_primary "+
"FROM musiccredit "+
"JOIN musicrelease AS release ON release=release.id "+
"JOIN artist ON artist=artist.id "+
"WHERE artist=$1 "
if !showHidden { query += "AND visible=true " }
if !show_hidden { query += "AND visible=true " }
query += "ORDER BY release_date DESC"
rows, err := repo.db.Query(query, artistID)
rows, err := db.Query(query, artistID)
if err != nil {
return nil, err
}
@ -66,10 +70,7 @@ func (repo *MusicRepositoryPostgres) GetArtistCredits(artistID string, showHidde
}
var credits []*model.Credit
for rows.Next() {
credit := &model.Credit{
Release: &model.Release{},
Artist: &model.Artist{},
}
var credit model.Credit
err = rows.Scan(
&credit.Release.ID,
&credit.Release.Title,
@ -84,42 +85,44 @@ func (repo *MusicRepositoryPostgres) GetArtistCredits(artistID string, showHidde
)
otherArtists := []NamePrimary{}
err = repo.db.Select(&otherArtists,
err = db.Select(&otherArtists,
"SELECT name,is_primary FROM artist "+
"JOIN musiccredit ON artist=id "+
"WHERE release=$1",
credit.Release.ID)
for _, otherCredit := range otherArtists {
credit.Release.Credits = append(credit.Release.Credits, &model.Credit{
Artist: &model.Artist{
Artist: model.Artist{
Name: otherCredit.Name,
},
Primary: otherCredit.Primary,
})
}
credits = append(credits, credit)
credits = append(credits, &credit)
}
return credits, nil
}
func (repo *MusicRepositoryPostgres) CreateArtist(
id string,
name string,
website string,
avatar string,
) error {
_, err := repo.db.Exec(
func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec(
"INSERT INTO artist (id, name, website, avatar) "+
"VALUES ($1, $2, $3, $4)",
id, name, website, avatar,
artist.ID,
artist.Name,
artist.Website,
artist.Avatar,
)
return err
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) UpdateArtist(artist *model.Artist) error {
_, err := repo.db.Exec(
func UpdateArtist(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec(
"UPDATE artist "+
"SET name=$2, website=$3, avatar=$4 "+
"WHERE id=$1",
@ -128,27 +131,22 @@ func (repo *MusicRepositoryPostgres) UpdateArtist(artist *model.Artist) error {
artist.Website,
artist.Avatar,
)
return err
}
func (repo *MusicRepositoryPostgres) UpdateArtistID(oldID string, newID string) error {
_, err := repo.db.Exec("UPDATE artist SET id=$2 WHERE id=$1", oldID, newID)
return err
}
func (repo *MusicRepositoryPostgres) UpdateArtistName(id string, name string) error {
_, err := repo.db.Exec("UPDATE artist SET name=$2 WHERE id=$1", id, name)
return err
}
func (repo *MusicRepositoryPostgres) UpdateArtistWebsite(id string, website string) error {
_, err := repo.db.Exec("UPDATE artist SET website=$2 WHERE id=$1", id, website)
return err
}
func (repo *MusicRepositoryPostgres) UpdateArtistAvatar(id string, avatar string) error {
_, err := repo.db.Exec("UPDATE artist SET avatar=$2 WHERE id=$1", id, avatar)
return err
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) DeleteArtist(id string) (string, error) {
var deletedID string
err := repo.db.Get(&deletedID, "DELETE FROM artist WHERE id=$1", id)
return deletedID, err
func DeleteArtist(db *sqlx.DB, artistID string) error {
_, err := db.Exec(
"DELETE FROM artist "+
"WHERE id=$1",
artistID,
)
if err != nil {
return err
}
return nil
}

View file

@ -1,28 +1,28 @@
package controller
import (
"errors"
"fmt"
"os"
"strconv"
"errors"
"fmt"
"os"
"strconv"
"arimelody-web/model/app"
"arimelody-web/model"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2"
)
func GetConfig() app.Config {
func GetConfig() model.Config {
configFile := os.Getenv("ARIMELODY_CONFIG")
if configFile == "" {
configFile = "config.toml"
}
config := app.Config{
config := model.Config{
BaseUrl: "https://arimelody.space",
Host: "0.0.0.0",
Port: 8080,
TrustedProxies: []string{ "127.0.0.1" },
DB: app.DBConfig{
DB: model.DBConfig{
Host: "127.0.0.1",
Port: 5432,
User: "arimelody",
@ -53,7 +53,7 @@ func GetConfig() app.Config {
return config
}
func handleConfigOverrides(config *app.Config) error {
func handleConfigOverrides(config *model.Config) error {
var err error
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }

View file

@ -1,15 +1,15 @@
package controller
import (
"arimelody-web/model/app"
"net/http"
"slices"
"strings"
"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 *app.AppState, r *http.Request) string {
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")

327
controller/release.go Normal file
View file

@ -0,0 +1,327 @@
package controller
import (
"fmt"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
var release = model.Release{}
err := db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id)
if err != nil {
return nil, err
}
if full {
// get credits
credits, err := GetReleaseCredits(db, id)
if err != nil {
return nil, fmt.Errorf("Credits: %s", err)
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
}
// get tracks
tracks, err := GetReleaseTracks(db, id)
if err != nil {
return nil, fmt.Errorf("Tracks: %s", err)
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
}
// get links
links, err := GetReleaseLinks(db, id)
if err != nil {
return nil, fmt.Errorf("Links: %s", err)
}
for _, link := range links {
release.Links = append(release.Links, link)
}
}
return &release, nil
}
func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*model.Release, error) {
var releases = []*model.Release{}
query := "SELECT * FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
query += " ORDER BY release_date DESC"
var err error
if limit > 0 {
err = db.Select(&releases, query + " LIMIT $1", limit)
} else {
err = db.Select(&releases, query)
}
if err != nil {
return nil, err
}
for _, release := range releases {
// get credits
credits, err := GetReleaseCredits(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Credits: %s", err)
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
}
if full {
// get tracks
tracks, err := GetReleaseTracks(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Tracks: %s", err)
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
}
// get links
links, err := GetReleaseLinks(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Links: %s", err)
}
for _, link := range links {
release.Links = append(release.Links, link)
}
}
}
return releases, nil
}
func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) {
query := "SELECT count(*) FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
var count int
err := db.Get(&count, query)
return count, err
}
func CreateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"INSERT INTO musicrelease "+
"(id, visible, title, description, type, release_date, artwork, buyname, buylink, copyright, copyrighturl) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
release.ID,
release.Visible,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2006-01-02 15:04:05"),
release.Artwork,
release.Buyname,
release.Buylink,
release.Copyright,
release.CopyrightURL,
)
if err != nil {
return err
}
return nil
}
func UpdateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"UPDATE musicrelease SET "+
"visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9, copyright=$10, copyrighturl=$11 "+
"WHERE id=$1",
release.ID,
release.Visible,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2006-01-02 15:04:05"),
release.Artwork,
release.Buyname,
release.Buylink,
release.Copyright,
release.CopyrightURL,
)
if err != nil {
return err
}
return nil
}
func UpdateReleaseTracks(db *sqlx.DB, releaseID string, new_tracks []string) error {
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID)
if err != nil {
return err
}
for i, trackID := range new_tracks {
_, err = tx.Exec(
"INSERT INTO musicreleasetrack "+
"(release, track, number) "+
"VALUES ($1, $2, $3)",
releaseID,
trackID,
i)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func UpdateReleaseCredits(db *sqlx.DB, releaseID string, new_credits []*model.Credit) error {
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musiccredit WHERE release=$1", releaseID)
if err != nil {
return err
}
for _, credit := range new_credits {
_, err = tx.Exec(
"INSERT INTO musiccredit "+
"(release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4)",
releaseID,
credit.Artist.ID,
credit.Role,
credit.Primary,
)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link) error {
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musiclink WHERE release=$1", releaseID)
if err != nil {
return err
}
for _, link := range new_links {
_, err := tx.Exec(
"INSERT INTO musiclink "+
"(release, name, url) "+
"VALUES ($1, $2, $3)",
releaseID,
link.Name,
link.URL,
)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func DeleteRelease(db *sqlx.DB, releaseID string) error {
_, err := db.Exec(
"DELETE FROM musicrelease "+
"WHERE id=$1",
releaseID,
)
if err != nil {
return err
}
return nil
}
func GetReleaseTracks(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := db.Select(&tracks,
"SELECT musictrack.* FROM musictrack "+
"JOIN musicreleasetrack ON track=id "+
"WHERE release=$1 "+
"ORDER BY number ASC",
releaseID,
)
if err != nil {
return nil, err
}
return tracks, nil
}
func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) {
rows, err := db.Query(
"SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+
"FROM musiccredit "+
"JOIN artist ON artist=artist.id "+
"JOIN musicrelease ON release=musicrelease.id "+
"WHERE musicrelease.id=$1 "+
"ORDER BY is_primary DESC",
releaseID,
)
if err != nil {
return nil, err
}
var credits []*model.Credit
for rows.Next() {
credit := model.Credit{}
rows.Scan(
&credit.Artist.ID,
&credit.Artist.Name,
&credit.Artist.Website,
&credit.Artist.Avatar,
&credit.Role,
&credit.Primary)
credits = append(credits, &credit)
}
return credits, nil
}
func GetReleaseLinks(db *sqlx.DB, releaseID string) ([]*model.Link, error) {
var links = []*model.Link{}
err := db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", releaseID)
if err != nil {
return nil, err
}
return links, nil
}

View file

@ -1,21 +1,21 @@
package controller
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/log"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
func GetSessionFromRequest(app *app.AppState, r *http.Request) (*model.Session, error) {
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err)
@ -25,7 +25,7 @@ func GetSessionFromRequest(app *app.AppState, r *http.Request) (*model.Session,
if sessionCookie != nil {
// fetch existing session
session, err = GetSession(app, sessionCookie.Value)
session, err = GetSession(app.DB, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
return nil, fmt.Errorf("Failed to retrieve session: %v", err)
@ -35,13 +35,13 @@ func GetSessionFromRequest(app *app.AppState, r *http.Request) (*model.Session,
if session.UserAgent != r.UserAgent() {
msg := "Session user agent mismatch. A cookie may have been hijacked!"
if session.Account != nil {
account, _ := app.AccountService.GetByID(session.Account.ID)
account, _ := GetAccountByID(app.DB, session.Account.ID)
msg += " (Account \"" + account.Username + "\")"
}
app.LogService.Warn(model.LOG_ACCOUNT, msg)
app.Log.Warn(log.TYPE_ACCOUNT, msg)
err = DeleteSession(app.DB, session.Token)
if err != nil {
app.LogService.Warn(model.LOG_ACCOUNT, "Failed to delete affected session")
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
}
return nil, nil
}
@ -137,7 +137,7 @@ func SetSessionError(db *sqlx.DB, session *model.Session, message string) error
return err
}
func GetSession(app *app.AppState, token string) (*model.Session, error) {
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
type dbSession struct {
model.Session
AttemptAccountID sql.NullString `db:"attempt_account"`
@ -145,7 +145,7 @@ func GetSession(app *app.AppState, token string) (*model.Session, error) {
}
session := dbSession{}
err := app.DB.Get(
err := db.Get(
&session,
"SELECT * FROM session WHERE token=$1",
token,
@ -155,14 +155,14 @@ func GetSession(app *app.AppState, token string) (*model.Session, error) {
}
if session.AccountID.Valid {
session.Account, err = app.AccountService.GetByID(session.AccountID.String)
session.Account, err = GetAccountByID(db, session.AccountID.String)
if err != nil {
return nil, err
}
}
if session.AttemptAccountID.Valid {
session.AttemptAccount, err = app.AccountService.GetByID(session.AttemptAccountID.String)
session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String)
if err != nil {
return nil, err
}

181
controller/track.go Normal file
View file

@ -0,0 +1,181 @@
package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func GetTrack(db *sqlx.DB, id string) (*model.Track, error) {
var track = model.Track{}
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
err := stmt.Get(&track, id)
if err != nil {
return nil, err
}
return &track, nil
}
func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := db.Select(&tracks, "SELECT * FROM musictrack")
if err != nil {
return nil, err
}
return tracks, nil
}
func GetTrackCount(db *sqlx.DB) (int, error) {
var count int
err := db.Get(&count, "SELECT count(*) FROM musictrack")
return count, err
}
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
if err != nil {
return nil, err
}
return tracks, nil
}
func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := db.Select(&tracks,
"SELECT * FROM musictrack "+
"WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
releaseID)
if err != nil {
return nil, err
}
return tracks, nil
}
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
var releases = []*model.Release{}
err := db.Select(&releases,
"SELECT id,title,type,release_date,artwork,buylink "+
"FROM musicrelease "+
"JOIN musicreleasetrack ON release=id "+
"WHERE track=$1 "+
"ORDER BY release_date",
trackID,
)
if err != nil {
return nil, err
}
type NamePrimary struct {
Name string `json:"name"`
Primary bool `json:"primary" db:"is_primary"`
}
for _, release := range releases {
// get artists
credits := []NamePrimary{}
err := db.Select(&credits,
"SELECT name,is_primary FROM artist "+
"JOIN musiccredit ON artist=artist.id "+
"JOIN musicrelease ON release=musicrelease.id "+
"WHERE musicrelease.id=$1", release.ID)
if err != nil {
return nil, err
}
for _, credit := range credits {
release.Credits = append(release.Credits, &model.Credit{
Artist: model.Artist{
Name: credit.Name,
},
Primary: credit.Primary,
})
}
// get tracks
tracks := []string{}
err = db.Select(&tracks, "SELECT track FROM musicreleasetrack WHERE release=$1", release.ID)
if err != nil {
return nil, err
}
for _, trackID := range tracks {
release.Tracks = append(release.Tracks, &model.Track{
ID: trackID,
})
}
}
return releases, nil
}
func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := db.Select(&tracks,
"SELECT id, title, description, lyrics, preview_url FROM musictrack "+
"WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack)",
)
if err != nil {
return nil, err
}
return tracks, nil
}
func CreateTrack(db *sqlx.DB, track *model.Track) (string, error) {
var trackID string
err := db.QueryRow(
"INSERT INTO musictrack (title, description, lyrics, preview_url) "+
"VALUES ($1, $2, $3, $4) "+
"RETURNING id",
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL,
).Scan(&trackID)
if err != nil {
return "", err
}
return trackID, nil
}
func UpdateTrack(db *sqlx.DB, track *model.Track) error {
_, err := db.Exec(
"UPDATE musictrack "+
"SET title=$2, description=$3, lyrics=$4, preview_url=$5 "+
"WHERE id=$1",
track.ID,
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL,
)
if err != nil {
return err
}
return nil
}
func DeleteTrack(db *sqlx.DB, trackID string) error {
_, err := db.Exec(
"DELETE FROM musictrack "+
"WHERE id=$1",
trackID,
)
if err != nil {
return err
}
return nil
}

View file

@ -1,8 +1,7 @@
package controller
import (
"arimelody-web/model/app"
"arimelody-web/model/twitch"
"arimelody-web/model"
"bytes"
"encoding/json"
"net/http"
@ -12,13 +11,13 @@ import (
const TWITCH_API_BASE = "https://api.twitch.tv/helix/"
func TwitchSetup(app *app.AppState) error {
app.Twitch = &twitch.State{}
func TwitchSetup(app *model.AppState) error {
app.Twitch = &model.TwitchState{}
err := RefreshTwitchToken(app)
return err
}
func RefreshTwitchToken(app *app.AppState) error {
func RefreshTwitchToken(app *model.AppState) error {
if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) {
return nil
}
@ -46,7 +45,7 @@ func RefreshTwitchToken(app *app.AppState) error {
return err
}
app.Twitch.Token = &twitch.OAuthToken{
app.Twitch.Token = &model.TwitchOAuthToken{
AccessToken: oauthResponse.AccessToken,
ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(),
TokenType: oauthResponse.TokenType,
@ -55,10 +54,10 @@ func RefreshTwitchToken(app *app.AppState) error {
return nil
}
var lastStreamState *twitch.StreamInfo
var lastStreamState *model.TwitchStreamInfo
var lastStreamStateAt time.Time
func GetTwitchStatus(app *app.AppState, broadcaster string) (*twitch.StreamInfo, error) {
func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) {
if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) {
return lastStreamState, nil
}
@ -77,7 +76,7 @@ func GetTwitchStatus(app *app.AppState, broadcaster string) (*twitch.StreamInfo,
}
type StreamsResponse struct {
Data []twitch.StreamInfo `json:"data"`
Data []model.TwitchStreamInfo `json:"data"`
}
streamInfo := StreamsResponse{}
err = json.NewDecoder(res.Body).Decode(&streamInfo)

View file

@ -1,16 +1,16 @@
package cursor
import (
"arimelody-web/model/app"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"arimelody-web/model"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/gorilla/websocket"
)
type CursorClient struct {
@ -49,7 +49,7 @@ var clients = make(map[int32]*CursorClient)
var broadcast = make(chan CursorMessage)
var mutex = &sync.Mutex{}
func StartCursor(app *app.AppState) {
func StartCursor(app *model.AppState) {
var includes = func (clients []*CursorClient, client *CursorClient) bool {
for _, c := range clients {
if c.ID == client.ID { return true }
@ -145,7 +145,7 @@ func handleClient(client *CursorClient) {
}
}
func Handler(app *app.AppState) http.HandlerFunc {
func Handler(app *model.AppState) http.HandlerFunc {
var upgrader = websocket.Upgrader{
CheckOrigin: func (r *http.Request) bool {
origin := r.Header.Get("Origin")

View file

@ -1,13 +1,13 @@
package discord
import (
"arimelody-web/model/app"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"arimelody-web/model"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
const API_ENDPOINT = "https://discord.com/api/v10"
@ -47,7 +47,7 @@ type (
}
)
func GetOAuthTokenFromCode(app *app.AppState, code string) (string, error) {
func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) {
// let's get an oauth token!
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT),
strings.NewReader(url.Values{
@ -99,7 +99,7 @@ func GetOAuthCallbackURI(baseURL string) string {
return fmt.Sprintf("%s/admin/login", baseURL)
}
func GetRedirectURI(app *app.AppState) string {
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,

View file

@ -1,16 +0,0 @@
package errors
type NotExistError struct {
query string
}
func NewNotExistError(query string) *NotExistError {
return &NotExistError{ query: query }
}
func (err *NotExistError) Error() string {
return err.query
}
func IsNotExistError(err error) bool {
_, ok := err.(*NotExistError)
return ok
}

View file

@ -1,26 +0,0 @@
package errors_test
import (
"arimelody-web/errors"
goErrors "errors"
"testing"
"gotest.tools/v3/assert"
)
func Test_NotExistError(t *testing.T) {
var err error
message := "entity does not exist"
t.Run("can create error", func(t *testing.T) {
err = errors.NewNotExistError(message)
assert.Error(t, err, message)
})
t.Run("validator returns true for valid error", func(t *testing.T) {
assert.Equal(t, errors.IsNotExistError(err), true)
})
t.Run("validator returns false for invalid error", func(t *testing.T) {
assert.Equal(t, errors.IsNotExistError(goErrors.New("other error")), false)
})
}

View file

@ -1,15 +0,0 @@
package errors
type ValidationError struct {
message string
}
func NewValidationError(message string) *ValidationError {
return &ValidationError{ message: message }
}
func (err *ValidationError) Error() string {
return err.message
}
func IsValidationError(err error) bool {
_, ok := err.(*ValidationError)
return ok
}

View file

@ -1,26 +0,0 @@
package errors_test
import (
"arimelody-web/errors"
goErrors "errors"
"testing"
"gotest.tools/v3/assert"
)
func Test_ValidationError(t *testing.T) {
var err error
message := "invalid input"
t.Run("can create error", func(t *testing.T) {
err = errors.NewValidationError(message)
assert.Error(t, err, message)
})
t.Run("validator returns true for valid error", func(t *testing.T) {
assert.Equal(t, errors.IsValidationError(err), true)
})
t.Run("validator returns false for invalid error", func(t *testing.T) {
assert.Equal(t, errors.IsValidationError(goErrors.New("other error")), false)
})
}

2
go.mod
View file

@ -10,9 +10,7 @@ require (
require golang.org/x/crypto v0.27.0 // indirect
require (
github.com/google/go-cmp v0.5.9 // indirect
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
gotest.tools/v3 v3.5.2 // indirect
)

4
go.sum
View file

@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@ -18,5 +16,3 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

143
log/log.go Normal file
View file

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

324
main.go
View file

@ -5,7 +5,6 @@ import (
"embed"
"errors"
"fmt"
"log"
stdLog "log"
"math"
"math/rand"
@ -22,19 +21,10 @@ import (
"arimelody-web/colour"
"arimelody-web/controller"
"arimelody-web/cursor"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/view"
accountRepo "arimelody-web/repository/account"
logRepo "arimelody-web/repository/log"
musicRepo "arimelody-web/repository/music"
repo "arimelody-web/repository/postgres"
accountService "arimelody-web/service/account"
logService "arimelody-web/service/log"
musicService "arimelody-web/service/music"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
@ -50,13 +40,9 @@ const HRT_DATE int64 = 1756478697
var publicFS embed.FS
func main() {
// TODO: switch to a new logger. this one kinda sucks
// i'll be so forreal i might write my own
logger := log.New(os.Stderr, "main", model.DEFAULT_LOG_FLAGS)
fmt.Printf("made with <3 by ari melody\n\n")
logger.Print("made with <3 by ari melody\n\n")
app := app.AppState{
app := model.AppState{
Config: controller.GetConfig(),
Twitch: nil,
PublicFS: publicFS,
@ -64,55 +50,44 @@ func main() {
// initialise database connection
if app.Config.DB.Host == "" {
logger.Fatalf("FATAL: db.host not provided! Exiting...\n")
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
os.Exit(1)
}
if app.Config.DB.Name == "" {
logger.Fatalf("FATAL: db.name not provided! Exiting...\n")
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
os.Exit(1)
}
if app.Config.DB.User == "" {
logger.Fatalf("FATAL: db.user not provided! Exiting...\n")
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
os.Exit(1)
}
if app.Config.DB.Pass == "" {
logger.Fatalf("FATAL: db.pass not provided! Exiting...\n")
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
os.Exit(1)
}
psqlDB, err := sqlx.Connect(
var err error
app.DB, err = sqlx.Connect(
"postgres",
fmt.Sprintf(
"host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable",
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
app.Config.DB.Host,
app.Config.DB.Port,
app.Config.DB.User,
app.Config.DB.Pass,
app.Config.DB.Name,
app.Config.DB.Pass,
),
)
if err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
os.Exit(1)
}
defer psqlDB.Close()
psqlDB.SetConnMaxLifetime(time.Minute * 3)
psqlDB.SetMaxOpenConns(10)
psqlDB.SetMaxIdleConns(10)
app.DB = psqlDB
app.DB.SetConnMaxLifetime(time.Minute * 3)
app.DB.SetMaxOpenConns(10)
app.DB.SetMaxIdleConns(10)
defer app.DB.Close()
logRepo := logRepo.NewLogRepositoryPostgres(psqlDB)
app.LogService = logService.NewLogService(
logRepo,
log.New(os.Stderr, "logger", model.DEFAULT_LOG_FLAGS),
)
accountRepo := accountRepo.NewAccountRepositoryPostgres(psqlDB)
app.AccountService = accountService.NewAccountService(
accountRepo,
log.New(os.Stderr, "account-repo", model.DEFAULT_LOG_FLAGS),
)
musicRepo := musicRepo.NewMusicRepositoryPostgres(psqlDB)
app.MusicService = musicService.NewMusicService(
musicRepo,
log.New(os.Stderr, "music-repo", model.DEFAULT_LOG_FLAGS),
)
app.Log = log.Logger{ DB: app.DB }
// handle command arguments
if len(os.Args) > 1 {
@ -121,18 +96,21 @@ func main() {
switch arg {
case "createTOTP":
if len(os.Args) < 4 {
logger.Fatalf("FATAL: `username` and `name` must be specified for createTOTP.\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := os.Args[3]
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
@ -145,116 +123,133 @@ func main() {
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
logger.Fatalf("FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
os.Exit(1)
}
logger.Fatalf("FATAL: Failed to create TOTP method: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
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)
logger.Printf("%s\n", url)
fmt.Printf("%s\n", url)
return
case "deleteTOTP":
if len(os.Args) < 4 {
logger.Fatalf("FATAL: `username` and `name` must be specified for deleteTOTP.\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := os.Args[3]
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
if err != nil {
logger.Fatalf("FATAL: Failed to create TOTP method: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
logger.Printf("TOTP method \"%s\" deleted.\n", totpName)
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return
case "listTOTP":
if len(os.Args) < 3 {
logger.Fatalf("FATAL: `username` must be specified for listTOTP.\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
logger.Fatalf("FATAL: Failed to create TOTP methods: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
os.Exit(1)
}
for i, totp := range totps {
logger.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt)
fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt)
}
if len(totps) == 0 {
logger.Printf("\"%s\" has no TOTP methods.\n", account.Username)
fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username)
}
return
case "testTOTP":
if len(os.Args) < 4 {
logger.Fatalf("FATAL: `username` and `name` must be specified for testTOTP.\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := os.Args[3]
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
os.Exit(1)
}
if totp == nil {
logger.Fatalf("FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
os.Exit(1)
}
code := controller.GenerateTOTP(totp.Secret, 0)
logger.Printf("%s\n", code)
fmt.Printf("%s\n", code)
return
case "cleanTOTP":
err := controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
logger.Fatalf("FATAL: Failed to clean up TOTP methods: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "TOTP methods pruned via config utility.")
logger.Printf("Cleaned up dangling TOTP methods successfully.\n")
app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
return
case "createInvite":
logger.Printf("Creating invite...\n")
fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
if err != nil {
logger.Fatalf("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)
}
app.LogService.Info(model.LOG_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
logger.Printf(
app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
fmt.Printf(
"Here you go! This code expires in %d hours: %s\n",
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
invite.Code,
@ -262,26 +257,28 @@ func main() {
return
case "purgeInvites":
logger.Printf("Deleting all invites...\n")
fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(app.DB)
if err != nil {
logger.Fatalf("FATAL: Failed to delete invites: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "Invites purged via config utility.")
logger.Printf("Invites deleted successfully.\n")
app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
fmt.Printf("Invites deleted successfully.\n")
return
case "listAccounts":
accounts, err := app.AccountService.GetAll()
accounts, err := controller.GetAllAccounts(app.DB)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch accounts: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err)
os.Exit(1)
}
for _, account := range accounts {
email := "<none>"
if account.Email.Valid { email = account.Email.String }
logger.Printf(
fmt.Printf(
"User: %s\n" +
"\tID: %s\n" +
"\tEmail: %s\n" +
@ -298,132 +295,150 @@ func main() {
case "changePassword":
if len(os.Args) < 4 {
logger.Fatalf("FATAL: `username` and `password` must be specified for changePassword\n")
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 := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
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 {
logger.Fatalf("FATAL: Failed to update password: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
os.Exit(1)
}
account.Password = string(hashedPassword)
if err = app.AccountService.ChangePassword(account.ID, string(hashedPassword)); err != nil {
logger.Fatalf("FATAL: Failed to update password: %v\n", err)
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.LogService.Info(model.LOG_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
logger.Printf("Password for \"%s\" updated successfully.\n", account.Username)
app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username)
return
case "deleteAccount":
if len(os.Args) < 3 {
logger.Fatalf("FATAL: `username` must be specified for deleteAccount\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
os.Exit(1)
}
username := os.Args[2]
logger.Printf("Deleting account \"%s\"...\n", username)
fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
logger.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username)
fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username)
res := ""
fmt.Scanln(&res)
if !strings.HasPrefix(res, "y") {
return
}
err = app.AccountService.Delete(account.ID)
err = controller.DeleteAccount(app.DB, account.ID)
if err != nil {
logger.Fatalf("FATAL: Failed to delete account: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
logger.Printf("Account \"%s\" deleted successfully.\n", account.Username)
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
case "lockAccount":
if len(os.Args) < 3 {
logger.Fatalf("FATAL: `username` must be specified for lockAccount\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
os.Exit(1)
}
username := os.Args[2]
logger.Printf("Unlocking account \"%s\"...\n", username)
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = app.AccountService.Lock(account.ID)
err = controller.LockAccount(app.DB, account.ID)
if err != nil {
logger.Fatalf("FATAL: Failed to lock account: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
logger.Printf("Account \"%s\" locked successfully.\n", account.Username)
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 {
logger.Fatalf("FATAL: `username` must be specified for unlockAccount\n")
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
os.Exit(1)
}
username := os.Args[2]
logger.Printf("Unlocking account \"%s\"...\n", username)
fmt.Printf("Unlocking account \"%s\"...\n", username)
account, err := app.AccountService.GetByUsername(username)
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
os.Exit(1)
}
if account == nil {
logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username)
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
os.Exit(1)
}
err = app.AccountService.Unlock(account.ID)
err = controller.UnlockAccount(app.DB, account.ID)
if err != nil {
logger.Fatalf("FATAL: Failed to unlock account: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
os.Exit(1)
}
app.LogService.Info(model.LOG_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
logger.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
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.LogService.Search([]model.LogLevel{}, []string{}, "", 100, 0)
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
if err != nil {
logger.Fatalf("FATAL: Failed to fetch logs: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
os.Exit(1)
}
for _, item := range(logs) {
levelStr := ""
switch item.Level {
case model.LEVEL_INFO:
case log.LEVEL_INFO:
levelStr = "INFO"
case model.LEVEL_WARN:
case log.LEVEL_WARN:
levelStr = "WARN"
default:
levelStr = fmt.Sprintf("? (%d)", item.Level)
}
logger.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
}
return
}
@ -449,66 +464,68 @@ func main() {
}
// handle DB migrations
if psqlDB != nil {
repo.CheckDBVersionAndMigrate(psqlDB)
}
controller.CheckDBVersionAndMigrate(app.DB)
if app.Config.Twitch != nil {
err = controller.TwitchSetup(&app)
if err != nil {
logger.Printf("WARN: Failed to set up Twitch integration: %v\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
}
}
// initial invite code
accountsCount, err := app.AccountService.GetCount()
accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) }
if accountsCount == 0 {
_, err := app.DB.Exec("DELETE FROM invite")
if err != nil {
logger.Fatalf("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)
}
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
if err != nil {
logger.Fatalf("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)
}
logger.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 {
logger.Fatalf("FATAL: Failed to clear expired sessions: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
os.Exit(1)
}
// delete expired invites
err = controller.DeleteExpiredInvites(app.DB)
if err != nil {
logger.Fatalf("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)
}
// clean up unconfirmed TOTP methods
err = controller.DeleteUnconfirmedTOTPs(app.DB)
if err != nil {
logger.Fatalf("FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
os.Exit(1)
}
go cursor.StartCursor(&app)
httpLogger := log.New(os.Stderr, "http", model.DEFAULT_LOG_FLAGS)
// start the web server!
mux := createServeMux(&app)
logger.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
stdLog.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
CheckRequest(&app, httpLogger, HTTPLog(httpLogger, DefaultHeaders(mux))),
CheckRequest(&app, HTTPLog(DefaultHeaders(mux))),
))
}
func createServeMux(app *app.AppState) *http.ServeMux {
func createServeMux(app *model.AppState) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
@ -551,7 +568,7 @@ var PoweredByStrings = []string{
"30 billion dollars in VC funding",
}
func CheckRequest(app *app.AppState, log *log.Logger, next http.Handler) http.Handler {
func CheckRequest(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requests with empty user agents are considered suspicious.
// every browser supplies them; hell, even curl supplies them.
@ -568,7 +585,8 @@ func CheckRequest(app *app.AppState, log *log.Logger, next http.Handler) http.Ha
if strings.HasSuffix(r.URL.Path, ".php") ||
strings.HasSuffix(r.URL.Path, ".php7") {
http.NotFound(w, r)
log.Printf(
fmt.Fprintf(
os.Stderr,
"WARN: Suspicious activity blocked: {\"path\":\"%s\",\"address\":\"%s\"}\n",
r.URL.Path,
r.RemoteAddr,
@ -618,7 +636,7 @@ func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.ResponseWriter.WriteHeader(status)
}
func HTTPLog(log *log.Logger, next http.Handler) http.Handler {
func HTTPLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
@ -640,7 +658,7 @@ func HTTPLog(log *log.Logger, next http.Handler) http.Handler {
if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
log.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate),
r.Method,
r.URL.Path,

View file

@ -16,8 +16,8 @@ type (
Email sql.NullString `json:"email" db:"email"`
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
Locked bool `json:"locked" db:"locked"`
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
Locked bool `json:"locked" db:"locked"`
Privileges []AccountPrivilege `json:"privileges"`
}

View file

@ -1,17 +1,11 @@
package app
package model
import (
"embed"
"github.com/jmoiron/sqlx"
"arimelody-web/model/twitch"
logService "arimelody-web/service/log"
//inviteService "arimelody-web/service/invite"
accountService "arimelody-web/service/account"
//sessionService "arimelody-web/service/session"
musicService "arimelody-web/service/music"
"arimelody-web/log"
)
type (
@ -49,13 +43,8 @@ type (
AppState struct {
DB *sqlx.DB
Config Config
Twitch *twitch.State
Log log.Logger
Twitch *TwitchState
PublicFS embed.FS
LogService *logService.LogService
//InviteService *inviteService.InviteService
AccountService *accountService.AccountService
//SesisonService *sessionService.SessionService
MusicService *musicService.MusicService
}
)

View file

@ -9,11 +9,9 @@ type (
}
)
const DEFAULT_AVATAR_URL = "/img/default-avatar.png"
func (artist Artist) GetAvatar() string {
if artist.Avatar == "" {
return DEFAULT_AVATAR_URL
return "/img/default-avatar.png"
}
return artist.Avatar
}

View file

@ -1,22 +1,21 @@
package model_test
package model
import (
"arimelody-web/model"
"testing"
"testing"
)
func Test_Artist_GetAvatar(t *testing.T) {
want := "testavatar.png"
artist := model.Artist{ Avatar: want }
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 = model.Artist{}
artist = Artist{}
want = model.DEFAULT_AVATAR_URL
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

@ -2,9 +2,9 @@ package model
type (
Credit struct {
Release *Release `json:"release"`
Artist *Artist `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary" db:"is_primary"`
Release Release `json:"release"`
Artist Artist `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary" db:"is_primary"`
}
)

View file

@ -1,12 +1,11 @@
package model_test
package model
import (
"arimelody-web/model"
"testing"
"testing"
)
func Test_Link_NormaliseName(t *testing.T) {
link := model.Link{
link := Link{
Name: "!c@o#o$l%-^a&w*e(s)o_m=e+-[l{i]n}k-0123456789ABCDEF",
}

View file

@ -1,36 +0,0 @@
package model
import (
"log"
"time"
)
type (
LogLevel int
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 (
DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds
LOG_ACCOUNT string = "account"
LOG_MUSIC string = "music"
LOG_ARTIST string = "artist"
LOG_BLOG string = "blog"
LOG_ARTWORK string = "artwork"
LOG_FILES string = "files"
LOG_MISC string = "misc"
LOG_CURSOR string = "cursor"
)
const (
LEVEL_INFO LogLevel = 0
LEVEL_WARN LogLevel = 1
)

View file

@ -34,8 +34,6 @@ const (
EP ReleaseType = "EP"
Compilation ReleaseType = "compilation"
Upcoming ReleaseType = "upcoming"
DEFAULT_RELEASE_ARTWORK_URL = "/img/default-cover-art.png"
)
// GETTERS
@ -54,7 +52,7 @@ func (release Release) PrintReleaseDate() string {
func (release Release) GetArtwork() string {
if release.Artwork == "" {
return DEFAULT_RELEASE_ARTWORK_URL
return "/img/default-cover-art.png"
}
return release.Artwork
}
@ -95,20 +93,3 @@ func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
return strings.Join(names[:], ", ")
}
}
func ValidReleaseType(releaseType string) (ReleaseType, bool) {
switch releaseType {
case "single":
return Single, true
case "album":
return Album, true
case "EP":
return EP, true
case "compilation":
return Compilation, true
case "upcoming":
return Upcoming, true
default:
return "", false
}
}

View file

@ -1,183 +1,157 @@
package model_test
package model
import (
"arimelody-web/model"
"strings"
"testing"
"time"
"gotest.tools/v3/assert"
"testing"
"time"
)
func Test_Release(t *testing.T) {
t.Run("prints correct description HTML", func(t *testing.T) {
release := model.Release{
Description: "this is\na test\n<strong>description!</strong>",
}
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
assert.Equal(
t,
string(release.GetDescriptionHTML()),
"this is<br>a test<br><strong>description!</strong>",
)
})
t.Run("prints correct release date", func(t *testing.T) {
release := model.Release{
ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC),
}
assert.Equal(t, release.TextReleaseDate(), "2025-07-26T16:00")
assert.Equal(t, release.PrintReleaseDate(), "26 July 2025")
})
t.Run("returns correct artwork", func(t *testing.T) {
artwork := "testartwork.png"
release := model.Release{ Artwork: artwork }
assert.Equal(t, release.GetArtwork(), artwork)
})
t.Run("returns placeholder artwork when empty", func(t *testing.T) {
release := model.Release{}
assert.Equal(t, release.GetArtwork(), model.DEFAULT_RELEASE_ARTWORK_URL)
})
t.Run("singles", func(t *testing.T) {
release := model.Release{
Tracks: []*model.Track{},
}
t.Run("false when no tracks are present", func(t *testing.T) {
assert.Equal(t, release.IsSingle(), false)
})
release.Tracks = append(release.Tracks, &model.Track{})
t.Run("true when one track is present", func(t *testing.T) {
assert.Equal(t, release.IsSingle(), true)
})
release.Tracks = append(release.Tracks, &model.Track{})
t.Run("false when >1 tracks are present", func(t *testing.T) {
assert.Equal(t, release.IsSingle(), false)
})
})
t.Run("released", func(t *testing.T) {
release := model.Release {
ReleaseDate: time.Now(),
}
t.Run("true when release date in the past", func(t *testing.T) {
assert.Equal(t, release.IsReleased(), true)
})
release.ReleaseDate = time.Now().Add(time.Hour)
t.Run("false when release date in the future", func(t *testing.T) {
assert.Equal(t, release.IsReleased(), false)
})
})
t.Run("printing artists", func(t *testing.T) {
artist1 := "ari melody"
artist2 := "aridoodle"
artist3 := "idk"
artist4 := "guest"
release := model.Release{}
t.Run("prints \"Unknown Artist\" when release has no credits", func(t *testing.T) {
assert.Equal(t, release.PrintArtists(false, true), "Unknown Artist")
})
release.Credits = append(
release.Credits,
&model.Credit{ Artist: &model.Artist{ Name: artist1 }, Primary: true },
)
t.Run("prints ONLY first artist name when release has one credit", func(t *testing.T) {
assert.Equal(t, release.PrintArtists(false, true), artist1)
})
release.Credits = append(release.Credits, []*model.Credit{
{ Artist: &model.Artist{ Name: artist2 }, Primary: true },
{ Artist: &model.Artist{ Name: artist3 }, Primary: false },
{ Artist: &model.Artist{ Name: artist4 }, Primary: true },
}...)
t.Run("can get only unique primary artist names", func(t *testing.T) {
assert.Equal(
t,
strings.Join(release.GetUniqueArtistNames(true), " "),
strings.Join([]string{ artist1, artist2, artist4 }, " "),
)
})
t.Run("can get only unique artist names", func(t *testing.T) {
assert.Equal(
t,
strings.Join(release.GetUniqueArtistNames(false), " "),
strings.Join([]string{ artist1, artist2, artist3, artist4 }, " "),
)
})
t.Run("can print only primary artists, with ampersands", func(t *testing.T) {
assert.Equal(
t,
release.PrintArtists(true, true),
"ari melody, aridoodle & guest",
)
})
t.Run("can print only primary artists, without ampersands", func(t *testing.T) {
assert.Equal(
t,
release.PrintArtists(true, false),
"ari melody, aridoodle, guest",
)
})
t.Run("can print all artists, with ampersands", func(t *testing.T) {
assert.Equal(
t,
release.PrintArtists(false, true),
"ari melody, aridoodle, idk & guest",
)
})
t.Run("can print all artists, without ampersands", func(t *testing.T) {
assert.Equal(
t,
release.PrintArtists(false, false),
"ari melody, aridoodle, idk, guest",
)
})
})
t.Run("validating release types", func(t *testing.T) {
t.Run("single", func(t *testing.T) {
releaseType, ok := model.ValidReleaseType("single")
assert.Equal(t, ok, true)
assert.Equal(t, releaseType, model.Single)
})
t.Run("album", func(t *testing.T) {
releaseType, ok := model.ValidReleaseType("album")
assert.Equal(t, ok, true)
assert.Equal(t, releaseType, model.Album)
})
t.Run("EP", func(t *testing.T) {
releaseType, ok := model.ValidReleaseType("EP")
assert.Equal(t, ok, true)
assert.Equal(t, releaseType, model.EP)
})
t.Run("compilation", func(t *testing.T) {
releaseType, ok := model.ValidReleaseType("compilation")
assert.Equal(t, ok, true)
assert.Equal(t, releaseType, model.Compilation)
})
t.Run("upcoming", func(t *testing.T) {
releaseType, ok := model.ValidReleaseType("upcoming")
assert.Equal(t, ok, true)
assert.Equal(t, releaseType, model.Upcoming)
})
t.Run("invalid", func(t *testing.T) {
releaseType, ok := model.ValidReleaseType("invalid")
assert.Equal(t, ok, false)
assert.Equal(t, string(releaseType), "")
})
})
// 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

@ -24,3 +24,8 @@ func (track Track) GetDescriptionHTML() template.HTML {
func (track Track) GetLyricsHTML() template.HTML {
return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>"))
}
// this function is stupid and i hate that i need it
func (track Track) Add(a int, b int) int {
return a + b
}

View file

@ -1,12 +1,11 @@
package model_test
package model
import (
"arimelody-web/model"
"testing"
"testing"
)
func Test_Track_DescriptionHTML(t *testing.T) {
track := model.Track{
track := Track{
Description: "this is\na test\n<strong>description!</strong>",
}
@ -20,7 +19,7 @@ func Test_Track_DescriptionHTML(t *testing.T) {
}
func Test_Track_LyricsHTML(t *testing.T) {
track := model.Track{
track := Track{
Lyrics: "these are\ntest\n<strong>lyrics!</strong>",
}
@ -32,3 +31,13 @@ func Test_Track_LyricsHTML(t *testing.T) {
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,4 +1,4 @@
package twitch
package model
import (
"fmt"
@ -7,17 +7,17 @@ import (
)
type (
OAuthToken struct {
TwitchOAuthToken struct {
AccessToken string
ExpiresAt time.Time
TokenType string
}
State struct {
Token *OAuthToken
TwitchState struct {
Token *TwitchOAuthToken
}
StreamInfo struct {
TwitchStreamInfo struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserLogin string `json:"user_login"`
@ -36,7 +36,7 @@ type (
}
)
func (info *StreamInfo) Thumbnail(width int, height int) string {
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

@ -153,7 +153,7 @@ header ul li a:hover {
flex-direction: column;
gap: 1rem;
border-bottom: 1px solid #888;
background-color: var(--background);
background: var(--background);
display: none;
}

View file

@ -16,7 +16,7 @@
body {
margin: 0;
padding: 0;
background-color: var(--background);
background: var(--background);
color: var(--on-background);
font-family: "Monaspace Argon", monospace;
font-size: 18px;
@ -150,7 +150,7 @@ a#backtotop:hover {
@keyframes list-item-fadein {
from {
opacity: 1;
background-color: var(--links);
background: var(--links);
}
to {

View file

@ -30,7 +30,7 @@ header {
background-size: cover;
background-position: center;
filter: blur(25px) saturate(25%) brightness(0.5);
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);;
animation: background-init .5s forwards,background-loop 30s ease-in-out infinite
}

View file

@ -1,37 +0,0 @@
package account
import "arimelody-web/model"
type AccountRepository interface {
GetAll() ([]*model.Account, error)
GetCount() (int, error)
// Fetches an account by ID, returning an error if one was encountered.
// If the account does not exist, both response fields are nil.
GetByID(id string) (*model.Account, error)
GetByUsername(username string) (*model.Account, error)
GetByEmail(email string) (*model.Account, error)
// Pulled this function: Cross-cutting concerns between accounts and sessions.
// Instead, fetch account ID from session and use GetByID()
// GetBySession(sessionToken string) (*model.Account, error)
// Create an account, returning the new account ID.
Create(username string, password string, email *string, avatarURL *string) (string, error)
// Deprecated in favour of more specialised Update* and Remove* functions.
Update(id string, username string, password string, email *string, avatarUrl *string) error
UpdateUsername(id string, username string) error
UpdatePassword(id string, password string) error
UpdateEmail(id string, email string) error
RemoveEmail(id string) error
UpdateAvatarURL(id string, avatarURL string) error
RemoveAvatar(id string) error
// Increment the number of account login failure attempts,
// returning the current fail count.
IncrementFails(id string) (int, error)
ResetFails(id string) error
SetLocked(id string, lock bool) error
Delete(id string) (string, error)
}

View file

@ -1,188 +0,0 @@
package account
import (
"arimelody-web/errors"
"arimelody-web/model"
"database/sql"
"slices"
"strconv"
)
type AccountRepositoryMemory struct {
accounts []*model.Account
}
var _ AccountRepository = new(AccountRepositoryMemory)
func NewAccountRepositoryMemory(accounts []*model.Account) *AccountRepositoryMemory {
return &AccountRepositoryMemory{ accounts: accounts }
}
func (repo *AccountRepositoryMemory) GetAll() ([]*model.Account, error) {
return repo.accounts, nil
}
func (repo *AccountRepositoryMemory) GetCount() (int, error) {
return len(repo.accounts), nil
}
func (repo *AccountRepositoryMemory) GetByID(id string) (*model.Account, error) {
index := slices.IndexFunc(repo.accounts, func(account *model.Account) bool {
return account.ID == id
})
if index == -1 { return nil, nil }
return repo.accounts[index], nil
}
func (repo *AccountRepositoryMemory) GetByUsername(username string) (*model.Account, error) {
for _, account := range repo.accounts {
if account.Username == username { return account, nil }
}
return nil, nil
}
func (repo *AccountRepositoryMemory) GetByEmail(email string) (*model.Account, error) {
for _, account := range repo.accounts {
if account.Email.Valid && account.Email.String == email {
return account, nil
}
}
return nil, nil
}
// Create an account, returning the new account ID.
func (repo *AccountRepositoryMemory) Create(username string, password string, email *string, avatarURL *string) (string, error) {
if account, err := repo.GetByUsername(username); err != nil {
return "", errors.NewNotExistError("Failed to fetch other acccounts by username")
} else if account != nil {
return "", errors.NewNotExistError("Account with this username already exists")
}
emailRef := ""
if email != nil { emailRef = *email }
avatarURLRef := ""
if avatarURL != nil { avatarURLRef = *avatarURL }
id := strconv.Itoa(len(repo.accounts))
repo.accounts = append(repo.accounts, &model.Account{
ID: id,
Username: username,
Password: password,
Email: sql.NullString{ String: emailRef, Valid: email != nil },
AvatarURL: sql.NullString{ String: avatarURLRef, Valid: avatarURL != nil },
})
return id, nil
}
// Intended for large profile updates. For smaller adjusments,
// more specialised Update* and Remove* functions should be used.
func (repo *AccountRepositoryMemory) Update(id string, username string, password string, email *string, avatarUrl *string) error {
if account, err := repo.GetByUsername(username); err != nil {
return errors.NewNotExistError("Failed to fetch other acccounts by username")
} else if account != nil && account.ID != id {
return errors.NewNotExistError("Account with this username already exists")
}
account, err := repo.GetByID(id)
if err != nil { return err }
account.Username = username
account.Password = password
account.Email.Valid = email != nil
if account.Email.Valid { account.Email.String = *email }
account.AvatarURL.Valid = avatarUrl != nil
if account.AvatarURL.Valid { account.AvatarURL.String = *avatarUrl }
return nil
}
func (repo *AccountRepositoryMemory) UpdateUsername(id string, username string) error {
if account, err := repo.GetByUsername(username); err != nil {
return errors.NewNotExistError("Failed to fetch other acccounts by username")
} else if account != nil && account.ID != id {
return errors.NewNotExistError("Account with this username already exists")
}
account, err := repo.GetByID(id)
if err != nil { return err }
if account == nil { return errors.NewNotExistError("Account does not exist") }
account.Username = username
return nil
}
func (repo *AccountRepositoryMemory) UpdatePassword(id string, password string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
if account == nil { return errors.NewNotExistError("Account does not exist") }
account.Password = password
return nil
}
func (repo *AccountRepositoryMemory) UpdateEmail(id string, email string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
if account == nil { return errors.NewNotExistError("Account does not exist") }
account.Email.Valid = true
account.Email.String = email
return nil
}
func (repo *AccountRepositoryMemory) RemoveEmail(id string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
if account == nil { return errors.NewNotExistError("Account does not exist") }
account.Email.Valid = false
account.Email.String = ""
return nil
}
func (repo *AccountRepositoryMemory) UpdateAvatarURL(id string, avatarURL string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
if account == nil { return errors.NewNotExistError("Account does not exist") }
account.AvatarURL.Valid = true
account.AvatarURL.String = avatarURL
return nil
}
func (repo *AccountRepositoryMemory) RemoveAvatar(id string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
if account == nil { return errors.NewNotExistError("Account does not exist") }
account.AvatarURL.Valid = false
account.AvatarURL.String = ""
return nil
}
// Increment the number of account login failure attempts,
// returning the current fail count.
func (repo *AccountRepositoryMemory) IncrementFails(id string) (int, error) {
account, err := repo.GetByID(id)
if err != nil { return 0, err }
account.FailAttempts += 1
return account.FailAttempts, nil
}
func (repo *AccountRepositoryMemory) ResetFails(id string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
account.FailAttempts = 0
return nil
}
func (repo *AccountRepositoryMemory) SetLocked(id string, locked bool) error {
account, err := repo.GetByID(id)
if err != nil { return err }
account.Locked = locked
return nil
}
func (repo *AccountRepositoryMemory) Delete(id string) (string, error) {
var deletedID string
newAccounts := []*model.Account{}
for _, account := range repo.accounts {
if account.ID == id {
deletedID = id
continue
}
newAccounts = append(newAccounts, account)
}
repo.accounts = newAccounts
return deletedID, nil
}

View file

@ -1,182 +0,0 @@
package account
import (
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type AccountRepositoryPostgres struct {
db *sqlx.DB
}
var _ AccountRepository = new(AccountRepositoryPostgres)
func NewAccountRepositoryPostgres(db *sqlx.DB) *AccountRepositoryPostgres {
return &AccountRepositoryPostgres{ db: db }
}
func (repo *AccountRepositoryPostgres) GetAll() ([]*model.Account, error) {
var accounts = []*model.Account{}
err := repo.db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
if err != nil {
return nil, err
}
return accounts, nil
}
func (repo *AccountRepositoryPostgres) GetCount() (int, error) {
accountsCount := 0
err := repo.db.Get(&accountsCount, "SELECT count(*) FROM account")
return accountsCount, err
}
func (repo *AccountRepositoryPostgres) GetByID(id string) (*model.Account, error) {
var account = model.Account{}
err := repo.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 (repo *AccountRepositoryPostgres) GetByUsername(username string) (*model.Account, error) {
var account = model.Account{}
err := repo.db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (repo *AccountRepositoryPostgres) GetByEmail(email string) (*model.Account, error) {
var account = model.Account{}
err := repo.db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (repo *AccountRepositoryPostgres) Create(
username string,
password string,
email *string,
avatarURL *string,
) (string, error) {
var id string
err := repo.db.Get(
&id,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
username,
password,
email,
avatarURL,
)
return id, err
}
func (repo *AccountRepositoryPostgres) Update(
id string,
username string,
password string,
email *string,
avatarURL *string,
) error {
_, err := repo.db.Exec(
"UPDATE account " +
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"WHERE id=$1",
id,
username,
password,
email,
avatarURL,
)
return err
}
func (repo *AccountRepositoryPostgres) UpdateUsername(id string, username string) error {
_, err := repo.db.Exec(
"UPDATE account SET username=$2 WHERE id=$1",
id, username,
)
return err
}
func (repo *AccountRepositoryPostgres) UpdatePassword(id string, password string) error {
_, err := repo.db.Exec(
"UPDATE account SET password=$2 WHERE id=$1",
id, password,
)
return err
}
func (repo *AccountRepositoryPostgres) UpdateEmail(id string, email string) error {
_, err := repo.db.Exec(
"UPDATE account SET email=$2 WHERE id=$1",
id, email,
)
return err
}
func (repo *AccountRepositoryPostgres) RemoveEmail(id string) error {
_, err := repo.db.Exec("UPDATE account SET email=NULL WHERE id=$1", id)
return err
}
func (repo *AccountRepositoryPostgres) UpdateAvatarURL(id string, avatarURL string) error {
_, err := repo.db.Exec(
"UPDATE account SET avatar_url=$2 WHERE id=$1",
id, avatarURL,
)
return err
}
func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error {
_, err := repo.db.Exec("UPDATE account SET avatar_url=NULL WHERE id=$1", id)
return err
}
// Increment the number of account login failure attempts,
// returning the current fail count.
func (repo *AccountRepositoryPostgres) IncrementFails(id string) (int, error) {
failAttempts := 0
err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", id)
return failAttempts, err
}
func (repo *AccountRepositoryPostgres) ResetFails(id string) error {
_, err := repo.db.Exec("UPDATE account SET fail_attempts = 0 WHERE id=$1", id)
return err
}
func (repo *AccountRepositoryPostgres) SetLocked(id string, locked bool) error {
_, err := repo.db.Exec("UPDATE account SET locked = $2 WHERE id=$1", id, locked)
return err
}
func (repo *AccountRepositoryPostgres) Delete(id string) (string, error) {
var deletedID string
err := repo.db.Get(&deletedID, "DELETE FROM account WHERE id=$1", id)
return deletedID, err
}

View file

@ -1,9 +0,0 @@
package log
import "arimelody-web/model"
type LogRepository interface {
Create(logLevel model.LogLevel, logType string, content string) error
Get(id string) (*model.Log, error)
Search(levelFilters []model.LogLevel, typeFilters []string, content string, limit int, offset int) ([]*model.Log, error)
}

View file

@ -1,108 +0,0 @@
package log
import (
"arimelody-web/model"
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type LogRepositoryPostgres struct {
db *sqlx.DB
}
var _ LogRepository = new(LogRepositoryPostgres)
func NewLogRepositoryPostgres(db *sqlx.DB) *LogRepositoryPostgres {
return &LogRepositoryPostgres{ db: db }
}
func (repo *LogRepositoryPostgres) Create(logLevel model.LogLevel, logType string, content string) error {
_, err := repo.db.Exec(
"INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
logLevel,
logType,
content,
)
return err
}
func (repo *LogRepositoryPostgres) Get(id string) (*model.Log, error) {
log := model.Log{}
err := repo.db.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
return &log, err
}
func (repo *LogRepositoryPostgres) Search(
levelFilters []model.LogLevel,
typeFilters []string,
content string,
limit int,
offset int,
) ([]*model.Log, error) {
logs := []*model.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 := repo.db.Select(&logs, query, params...)
if err != nil {
return nil, err
}
return logs, nil
}

View file

@ -1,135 +0,0 @@
package music
import (
"arimelody-web/errors"
"arimelody-web/model"
"slices"
)
func (repo *MusicRepositoryMemory) GetAllArtists() ([]*model.Artist, error) {
return repo.artists, nil
}
func (repo *MusicRepositoryMemory) GetArtistCount() (int, error) {
return len(repo.artists), nil
}
func (repo *MusicRepositoryMemory) GetArtistByID(id string) (*model.Artist, error) {
index := slices.IndexFunc(repo.artists, func(artist *model.Artist) bool {
return artist.ID == id
})
if index == -1 { return nil, nil }
return repo.artists[index], nil
}
func (repo *MusicRepositoryMemory) GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) {
release, err := repo.GetReleaseByID(releaseID)
if err != nil { return nil, err }
artists, err := repo.GetAllArtists()
if err != nil { return nil, err }
artistsNotOnRelease := []*model.Artist{}
for _, artist := range artists {
if !slices.ContainsFunc(release.Credits, func(credit *model.Credit) bool {
return credit.Artist.ID == artist.ID
}) {
artistsNotOnRelease = append(artistsNotOnRelease, artist)
}
}
return artistsNotOnRelease, nil
}
func (repo *MusicRepositoryMemory) GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) {
releases, err := repo.GetAllReleases(!showHidden, 0)
if err != nil { return nil, err }
credits := []*model.Credit{}
for _, release := range releases {
credits = append(credits, slices.DeleteFunc(
release.Credits,
func(credit *model.Credit) bool {
return credit.Artist.ID != artistID
},
)...)
}
return credits, nil
}
func (repo *MusicRepositoryMemory) CreateArtist(
id string,
name string,
website string,
avatar string,
) error {
if artist, err := repo.GetArtistByID(id); err != nil {
if !errors.IsNotExistError(err) { return err }
} else {
if artist != nil { return errors.NewValidationError("Artist with this ID already exists") }
}
repo.artists = append(repo.artists, &model.Artist{
ID: id,
Name: name,
Website: website,
Avatar: avatar,
})
return nil
}
func (repo *MusicRepositoryMemory) UpdateArtist(artist *model.Artist) error {
repoArtist, err := repo.GetArtistByID(artist.ID)
if err != nil { return err }
if repoArtist == nil { return errors.NewNotExistError("Artist does not exist") }
repoArtist.Name = artist.Name
repoArtist.Website = artist.Website
repoArtist.Avatar = artist.Avatar
return nil
}
func (repo *MusicRepositoryMemory) UpdateArtistID(oldID string, newID string) error {
artist, err := repo.GetArtistByID(oldID)
if err != nil { return err }
if artist == nil { return errors.NewNotExistError("Artist does not exist") }
artist.ID = newID
return nil
}
func (repo *MusicRepositoryMemory) UpdateArtistName(id string, name string) error {
artist, err := repo.GetArtistByID(id)
if err != nil { return err }
if artist == nil { return errors.NewNotExistError("Artist does not exist") }
artist.Name = name
return nil
}
func (repo *MusicRepositoryMemory) UpdateArtistWebsite(id string, website string) error {
artist, err := repo.GetArtistByID(id)
if err != nil { return err }
if artist == nil { return errors.NewNotExistError("Artist does not exist") }
artist.Website = website
return nil
}
func (repo *MusicRepositoryMemory) UpdateArtistAvatar(id string, avatar string) error {
artist, err := repo.GetArtistByID(id)
if err != nil { return err }
if artist == nil { return errors.NewNotExistError("Artist does not exist") }
artist.Avatar = avatar
return nil
}
func (repo *MusicRepositoryMemory) DeleteArtist(id string) (string, error) {
var deletedID string
newArtists := []*model.Artist{}
for _, artist := range repo.artists {
if artist.ID == id {
deletedID = id
continue
}
newArtists = append(newArtists, artist)
}
repo.artists = newArtists
return deletedID, nil
}

View file

@ -1,110 +0,0 @@
package music
import (
"arimelody-web/model"
"time"
"github.com/jmoiron/sqlx"
)
type MusicRepository interface {
// artists
GetAllArtists() ([]*model.Artist, error)
GetArtistCount() (int, error)
// Fetches an artist by ID, returning an error if one was encountered.
// If the artist does not exist, both response fields are nil.
GetArtistByID(id string) (*model.Artist, error)
GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error)
GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error)
CreateArtist(id string, name string, website string, avatar string) error
UpdateArtist(artist *model.Artist) error
UpdateArtistID(oldID string, newID string) error
UpdateArtistName(id string, name string) error
UpdateArtistWebsite(id string, website string) error
UpdateArtistAvatar(id string, avatar string) error
DeleteArtist(id string) (string, error)
// releases
// Fetch all releases.
// Filters to visible releases if `onlyVisible = true`.
// If `limit > 0`, limits the number of results.
GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error)
GetReleaseCount(onlyVisible bool) (int, error)
GetReleaseByID(id string) (*model.Release, error)
GetReleaseTracks(id string) ([]*model.Track, error)
GetReleaseCredits(id string) ([]*model.Credit, error)
GetReleaseLinks(id string) ([]*model.Link, error)
CreateRelease(id string, title string, releaseType model.ReleaseType, releaseDate time.Time, artworkURL string) error
UpdateRelease(release *model.Release) error
UpdateReleaseID(oldID string, newID string) error
UpdateReleaseVisibility(id string, visible bool) error
UpdateReleaseTitle(id string, title string) error
UpdateReleaseDescription(id string, description string) error
UpdateReleaseType(id string, releaseType model.ReleaseType) error
UpdateReleaseDate(id string, releaseDate time.Time) error
UpdateReleaseArtwork(id string, artwork string) error
UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error
UpdateReleaseCopyright(id string, copyright string, url string) error
UpdateReleaseTracks(id string, newTrackIDs []string) error
UpdateReleaseCredits(id string, newCredits []*model.Credit) error
UpdateReleaseLinks(id string, newLinks []*model.Link) error
DeleteRelease(id string) (string, error)
// tracks
GetAllTracks() ([]*model.Track, error)
GetTrackCount() (int, error)
GetTrackByID(id string) (*model.Track, error)
GetOrphanTracks() ([]*model.Track, error)
GetTracksNotOnRelease(releaseID string) ([]*model.Track, error)
GetTrackReleases(trackID string) ([]*model.Release, error)
CreateTrack(title string, description string, lyrics string, previewURL string) (string, error)
UpdateTrack(track *model.Track) error
UpdateTrackTitle(id string, title string) error
UpdateTrackDescription(id string, description string) error
UpdateTrackLyrics(id string, lyrics string) error
UpdateTrackPreviewURL(id string, previewURL string) error
DeleteTrack(id string) (string, error)
}
type (
MusicRepositoryPostgres struct {
db *sqlx.DB
}
MusicRepositoryMemory struct {
artists []*model.Artist
releases []*model.Release
tracks []*model.Track
}
)
var _ MusicRepository = new(MusicRepositoryPostgres)
func NewMusicRepositoryPostgres(db *sqlx.DB) *MusicRepositoryPostgres {
return &MusicRepositoryPostgres{ db: db }
}
var _ MusicRepository = new(MusicRepositoryMemory)
func NewMusicRepositoryMemory(
artists []*model.Artist,
releases []*model.Release,
tracks []*model.Track,
) *MusicRepositoryMemory {
return &MusicRepositoryMemory{
artists: artists,
releases: releases,
tracks: tracks,
}
}

View file

@ -1,228 +0,0 @@
package music
import (
"arimelody-web/errors"
"arimelody-web/model"
"fmt"
"slices"
"time"
)
func (repo *MusicRepositoryMemory) GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) {
releases := []*model.Release{}
for _, release := range repo.releases {
if !onlyVisible || release.Visible {
releases = append(releases, release)
}
}
return releases, nil
}
func (repo *MusicRepositoryMemory) GetReleaseCount(onlyVisible bool) (int, error) {
releaseCount := 0
for _, release := range repo.releases {
if !onlyVisible || release.Visible {
releaseCount++
}
}
return releaseCount, nil
}
func (repo *MusicRepositoryMemory) GetReleaseByID(id string) (*model.Release, error) {
index := slices.IndexFunc(repo.releases, func(release *model.Release) bool {
return release.ID == id
})
if index == -1 { return nil, nil }
return repo.releases[index], nil
}
func (repo *MusicRepositoryMemory) GetReleaseTracks(id string) ([]*model.Track, error) {
release, err := repo.GetReleaseByID(id)
if err != nil { return nil, err }
return release.Tracks, nil
}
func (repo *MusicRepositoryMemory) GetReleaseCredits(id string) ([]*model.Credit, error) {
release, err := repo.GetReleaseByID(id)
if err != nil { return nil, err }
return release.Credits, nil
}
func (repo *MusicRepositoryMemory) GetReleaseLinks(id string) ([]*model.Link, error) {
release, err := repo.GetReleaseByID(id)
if err != nil { return nil, err }
return release.Links, nil
}
func (repo *MusicRepositoryMemory) CreateRelease(
id string,
title string,
releaseType model.ReleaseType,
releaseDate time.Time,
artworkURL string,
) error {
if release, err := repo.GetReleaseByID(id); err != nil {
if !errors.IsNotExistError(err) { return err }
} else {
if release != nil { return errors.NewValidationError("Release with this ID already exists") }
}
repo.releases = append(repo.releases, &model.Release{
ID: id,
Title: title,
ReleaseType: releaseType,
ReleaseDate: releaseDate,
Artwork: artworkURL,
})
return nil
}
func (repo *MusicRepositoryMemory) UpdateRelease(release *model.Release) error {
repoRelease, err := repo.GetReleaseByID(release.ID)
if err != nil { return err }
if repoRelease == nil { return errors.NewNotExistError("Release does not exist") }
repoRelease.Visible = release.Visible
repoRelease.Title = release.Title
repoRelease.Description = release.Description
repoRelease.ReleaseType = release.ReleaseType
repoRelease.ReleaseDate = release.ReleaseDate
repoRelease.Artwork = release.Artwork
repoRelease.Buyname = release.Buyname
repoRelease.Buylink = release.Buylink
repoRelease.Copyright = release.Copyright
repoRelease.CopyrightURL = release.CopyrightURL
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseID(oldID string, newID string) error {
release, err := repo.GetReleaseByID(oldID)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.ID = newID
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseVisibility(id string, visible bool) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Visible = visible
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseTitle(id string, title string) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Title = title
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseDescription(id string, description string) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Description = description
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseType(id string, releaseType model.ReleaseType) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.ReleaseType = releaseType
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseDate(id string, releaseDate time.Time) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.ReleaseDate = releaseDate
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseArtwork(id string, artwork string) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Artwork = artwork
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Buyname = buyName
release.Buylink = buyLink
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseCopyright(id string, copyright string, url string) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Copyright = copyright
release.CopyrightURL = url
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseTracks(id string, newTrackIDs []string) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
tracks := []*model.Track{}
for _, trackID := range newTrackIDs {
track, err := repo.GetTrackByID(trackID)
if err != nil {
if errors.IsNotExistError(err) {
return errors.NewNotExistError(fmt.Sprintf("Track %s does not exist", trackID))
}
return err
}
tracks = append(tracks, track)
}
release.Tracks = tracks
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseCredits(id string, newCredits []*model.Credit) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
for _, credit := range newCredits {
if credit.Artist == nil { return errors.NewValidationError("Credit artist cannot be empty") }
if len(credit.Artist.ID) == 0 { return errors.NewValidationError("Credit artist ID cannot be empty") }
if artist, err := repo.GetArtistByID(credit.Artist.ID); err != nil {
return err
} else if artist == nil {
return errors.NewNotExistError(fmt.Sprintf("Artist '%s' does not exist", credit.Artist.ID))
} else {
credit.Release = release
credit.Artist = artist
}
}
release.Credits = newCredits
return nil
}
func (repo *MusicRepositoryMemory) UpdateReleaseLinks(id string, newLinks []*model.Link) error {
release, err := repo.GetReleaseByID(id)
if err != nil { return err }
if release == nil { return errors.NewNotExistError("Release does not exist") }
release.Links = newLinks
return nil
}
func (repo *MusicRepositoryMemory) DeleteRelease(id string) (string, error) {
var deletedID string
newReleases := []*model.Release{}
for _, release := range repo.releases {
if release.ID == id {
deletedID = id
continue
}
newReleases = append(newReleases, release)
}
repo.releases = newReleases
return deletedID, nil
}

View file

@ -1,303 +0,0 @@
package music
import (
"arimelody-web/model"
"time"
)
func (repo *MusicRepositoryPostgres) GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) {
var releases = []*model.Release{}
query := "SELECT * FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
query += " ORDER BY release_date DESC"
var err error
if limit > 0 {
err = repo.db.Select(&releases, query + " LIMIT $1", limit)
} else {
err = repo.db.Select(&releases, query)
}
if err != nil {
return nil, err
}
return releases, nil
}
func (repo *MusicRepositoryPostgres) GetReleaseCount(onlyVisible bool) (int, error) {
query := "SELECT count(*) FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
var count int
err := repo.db.Get(&count, query)
return count, err
}
func (repo *MusicRepositoryPostgres) GetReleaseByID(id string) (*model.Release, error) {
var release = model.Release{}
err := repo.db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id)
if err != nil { return nil, err }
return &release, nil
}
func (repo *MusicRepositoryPostgres) GetReleaseTracks(releaseID string) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := repo.db.Select(&tracks,
"SELECT musictrack.* FROM musictrack "+
"JOIN musicreleasetrack ON track=id "+
"WHERE release=$1 "+
"ORDER BY number ASC",
releaseID,
)
if err != nil {
return nil, err
}
return tracks, nil
}
func (repo *MusicRepositoryPostgres) GetReleaseCredits(releaseID string) ([]*model.Credit, error) {
rows, err := repo.db.Query(
"SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+
"FROM musiccredit "+
"JOIN artist ON artist=artist.id "+
"JOIN musicrelease ON release=musicrelease.id "+
"WHERE musicrelease.id=$1 "+
"ORDER BY is_primary DESC",
releaseID,
)
if err != nil {
return nil, err
}
var credits []*model.Credit
for rows.Next() {
credit := &model.Credit{
Artist: &model.Artist{},
}
rows.Scan(
&credit.Artist.ID,
&credit.Artist.Name,
&credit.Artist.Website,
&credit.Artist.Avatar,
&credit.Role,
&credit.Primary)
credits = append(credits, credit)
}
return credits, nil
}
func (repo *MusicRepositoryPostgres) GetReleaseLinks(releaseID string) ([]*model.Link, error) {
var links = []*model.Link{}
err := repo.db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", releaseID)
if err != nil {
return nil, err
}
return links, nil
}
func (repo *MusicRepositoryPostgres) CreateRelease(
id string,
title string,
releaseType model.ReleaseType,
releaseDate time.Time,
artworkURL string,
) error {
_, err := repo.db.Exec(
"INSERT INTO musicrelease "+
"(id, title, type, release_date, artwork) "+
"VALUES ($1, $2, $3, $4, $5)",
id,
title,
releaseType,
releaseDate.Format("2006-01-02 15:04:05"),
artworkURL,
)
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) UpdateRelease(release *model.Release) error {
_, err := repo.db.Exec(
"UPDATE musicrelease SET "+
"visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9, copyright=$10, copyrighturl=$11 "+
"WHERE id=$1",
release.ID,
release.Visible,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2006-01-02 15:04:05"),
release.Artwork,
release.Buyname,
release.Buylink,
release.Copyright,
release.CopyrightURL,
)
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) UpdateReleaseID(oldID string, newID string) error {
_, err := repo.db.Exec("UPDATE musicrelease SET id=$2 WHERE id=$1", oldID, newID)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseVisibility(id string, visible bool) error {
_, err := repo.db.Exec("UPDATE musicrelease SET visible=$2 WHERE id=$1", id, visible)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseTitle(id string, title string) error {
_, err := repo.db.Exec("UPDATE musicrelease SET title=$2 WHERE id=$1", id, title)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseDescription(id string, description string) error {
_, err := repo.db.Exec("UPDATE musicrelease SET description=$2 WHERE id=$1", id, description)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseType(id string, releaseType model.ReleaseType) error {
_, err := repo.db.Exec("UPDATE musicrelease SET type=$2 WHERE id=$1", id, releaseType)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseDate(id string, releaseDate time.Time) error {
_, err := repo.db.Exec(
"UPDATE musicrelease SET release_date=$2 WHERE id=$1",
id,
releaseDate.Format("2006-01-02 15:04:05"),
)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseArtwork(id string, artwork string) error {
_, err := repo.db.Exec("UPDATE musicrelease SET artwork=$2 WHERE id=$1", id, artwork)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error {
_, err := repo.db.Exec(
"UPDATE musicrelease SET buyname=$2,buylink=$3 WHERE id=$1",
id, buyName, buyLink,
)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseCopyright(id string, copyright string, url string) error {
_, err := repo.db.Exec(
"UPDATE musicrelease SET copyright=$2,copyrighturl=$3 WHERE id=$1",
id, copyright, url,
)
return err
}
func (repo *MusicRepositoryPostgres) UpdateReleaseTracks(releaseID string, newTrackIDs []string) error {
tx, err := repo.db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID)
if err != nil {
return err
}
for i, trackID := range newTrackIDs {
_, err = tx.Exec(
"INSERT INTO musicreleasetrack "+
"(release, track, number) "+
"VALUES ($1, $2, $3)",
releaseID,
trackID,
i)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) UpdateReleaseCredits(releaseID string, newCredits []*model.Credit) error {
tx, err := repo.db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musiccredit WHERE release=$1", releaseID)
if err != nil {
return err
}
for _, credit := range newCredits {
_, err = tx.Exec(
"INSERT INTO musiccredit "+
"(release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4)",
releaseID,
credit.Artist.ID,
credit.Role,
credit.Primary,
)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) UpdateReleaseLinks(releaseID string, newLinks []*model.Link) error {
tx, err := repo.db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musiclink WHERE release=$1", releaseID)
if err != nil {
return err
}
for _, link := range newLinks {
_, err := tx.Exec(
"INSERT INTO musiclink "+
"(release, name, url) "+
"VALUES ($1, $2, $3)",
releaseID,
link.Name,
link.URL,
)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) DeleteRelease(id string) (string, error) {
var deletedID string
err := repo.db.Get(&deletedID, "DELETE FROM musicrelease WHERE id=$1", id)
return deletedID, err
}

View file

@ -1,123 +0,0 @@
package music
import (
"arimelody-web/errors"
"arimelody-web/model"
"slices"
"strconv"
)
func (repo *MusicRepositoryMemory) GetAllTracks() ([]*model.Track, error) {
return repo.tracks, nil
}
func (repo *MusicRepositoryMemory) GetTrackCount() (int, error) {
return len(repo.tracks), nil
}
func (repo *MusicRepositoryMemory) GetTrackByID(id string) (*model.Track, error) {
index := slices.IndexFunc(repo.tracks, func(track *model.Track) bool {
return track.ID == id
})
if index == -1 { return nil, nil }
return repo.tracks[index], nil
}
func (repo *MusicRepositoryMemory) GetOrphanTracks() ([]*model.Track, error) {
return slices.DeleteFunc(repo.tracks, func(track *model.Track) bool {
return slices.ContainsFunc(repo.releases, func(release *model.Release) bool {
return slices.ContainsFunc(release.Tracks, func(releaseTrack *model.Track) bool {
return releaseTrack.ID == track.ID
})
})
}), nil
}
func (repo *MusicRepositoryMemory) GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) {
release, err := repo.GetReleaseByID(releaseID)
if err != nil { return nil, err }
return slices.DeleteFunc(repo.tracks, func(track *model.Track) bool {
return slices.ContainsFunc(release.Tracks, func(releaseTrack *model.Track) bool {
return releaseTrack.ID == track.ID
})
}), nil
}
func (repo *MusicRepositoryMemory) GetTrackReleases(trackID string) ([]*model.Release, error) {
return slices.DeleteFunc(repo.releases, func(release *model.Release) bool {
return !slices.ContainsFunc(release.Tracks, func(track *model.Track) bool {
return track.ID == trackID
})
}), nil
}
func (repo *MusicRepositoryMemory) CreateTrack(
title string,
description string,
lyrics string,
previewURL string,
) (string, error) {
id := strconv.Itoa(len(repo.tracks))
repo.tracks = append(repo.tracks, &model.Track{
ID: id,
Title: title,
Description: description,
Lyrics: lyrics,
PreviewURL: previewURL,
})
return id, nil
}
func (repo *MusicRepositoryMemory) UpdateTrack(track *model.Track) error {
repoTrack, err := repo.GetTrackByID(track.ID)
if err != nil { return err }
if repoTrack == nil { return errors.NewNotExistError("Track does not exist") }
repoTrack.Title = track.Title
repoTrack.Description = track.Description
repoTrack.Lyrics = track.Lyrics
repoTrack.PreviewURL = track.PreviewURL
return nil
}
func (repo *MusicRepositoryMemory) UpdateTrackTitle(id string, title string) error {
repoTrack, err := repo.GetTrackByID(id)
if err != nil { return err }
if repoTrack == nil { return errors.NewNotExistError("Track does not exist") }
repoTrack.Title = title
return nil
}
func (repo *MusicRepositoryMemory) UpdateTrackDescription(id string, description string) error {
repoTrack, err := repo.GetTrackByID(id)
if err != nil { return err }
if repoTrack == nil { return errors.NewNotExistError("Track does not exist") }
repoTrack.Description = description
return nil
}
func (repo *MusicRepositoryMemory) UpdateTrackLyrics(id string, lyrics string) error {
repoTrack, err := repo.GetTrackByID(id)
if err != nil { return err }
if repoTrack == nil { return errors.NewNotExistError("Track does not exist") }
repoTrack.Lyrics = lyrics
return nil
}
func (repo *MusicRepositoryMemory) UpdateTrackPreviewURL(id string, previewURL string) error {
repoTrack, err := repo.GetTrackByID(id)
if err != nil { return err }
if repoTrack == nil { return errors.NewNotExistError("Track does not exist") }
repoTrack.PreviewURL = previewURL
return nil
}
func (repo *MusicRepositoryMemory) DeleteTrack(id string) (string, error) {
var deletedID string
newTracks := []*model.Track{}
for _, track := range repo.tracks {
if track.ID == id {
deletedID = id
continue
}
newTracks = append(newTracks, track)
}
repo.tracks = newTracks
return deletedID, nil
}

View file

@ -1,137 +0,0 @@
package music
import (
"arimelody-web/model"
)
func (repo *MusicRepositoryPostgres) GetAllTracks() ([]*model.Track, error) {
var tracks = []*model.Track{}
err := repo.db.Select(&tracks, "SELECT * FROM musictrack")
if err != nil {
return nil, err
}
return tracks, nil
}
func (repo *MusicRepositoryPostgres) GetTrackCount() (int, error) {
var count int
err := repo.db.Get(&count, "SELECT count(*) FROM musictrack")
return count, err
}
func (repo *MusicRepositoryPostgres) GetTrackByID(id string) (*model.Track, error) {
var track = model.Track{}
stmt, _ := repo.db.Preparex("SELECT * FROM musictrack WHERE id=$1")
err := stmt.Get(&track, id)
if err != nil {
return nil, err
}
return &track, nil
}
func (repo *MusicRepositoryPostgres) GetOrphanTracks() ([]*model.Track, error) {
var tracks = []*model.Track{}
err := repo.db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
if err != nil {
return nil, err
}
return tracks, nil
}
func (repo *MusicRepositoryPostgres) GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := repo.db.Select(&tracks,
"SELECT * FROM musictrack "+
"WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
releaseID)
if err != nil {
return nil, err
}
return tracks, nil
}
func (repo *MusicRepositoryPostgres) GetTrackReleases(trackID string) ([]*model.Release, error) {
var releases = []*model.Release{}
err := repo.db.Select(&releases,
"SELECT id,title,type,release_date,artwork,buylink "+
"FROM musicrelease "+
"JOIN musicreleasetrack ON release=id "+
"WHERE track=$1 "+
"ORDER BY release_date",
trackID,
)
if err != nil { return nil, err }
return releases, nil
}
func (repo *MusicRepositoryPostgres) CreateTrack(
title string,
description string,
lyrics string,
previewURL string,
) (string, error) {
var trackID string
err := repo.db.QueryRow(
"INSERT INTO musictrack (title, description, lyrics, preview_url) "+
"VALUES ($1, $2, $3, $4) "+
"RETURNING id",
title,
description,
lyrics,
previewURL,
).Scan(&trackID)
if err != nil {
return "", err
}
return trackID, nil
}
func (repo *MusicRepositoryPostgres) UpdateTrack(track *model.Track) error {
_, err := repo.db.Exec(
"UPDATE musictrack "+
"SET title=$2, description=$3, lyrics=$4, preview_url=$5 "+
"WHERE id=$1",
track.ID,
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL,
)
if err != nil {
return err
}
return nil
}
func (repo *MusicRepositoryPostgres) UpdateTrackTitle(id string, title string) error {
_, err := repo.db.Exec("UPDATE musictrack SET title=$2 WHERE id=$1", id, title)
return err
}
func (repo *MusicRepositoryPostgres) UpdateTrackDescription(id string, description string) error {
_, err := repo.db.Exec("UPDATE musictrack SET description=$2 WHERE id=$1", id, description)
return err
}
func (repo *MusicRepositoryPostgres) UpdateTrackLyrics(id string, lyrics string) error {
_, err := repo.db.Exec("UPDATE musictrack SET lyrics=$2 WHERE id=$1", id, lyrics)
return err
}
func (repo *MusicRepositoryPostgres) UpdateTrackPreviewURL(id string, previewURL string) error {
_, err := repo.db.Exec("UPDATE musictrack SET preview_url=$2 WHERE id=$1", id, previewURL)
return err
}
func (repo *MusicRepositoryPostgres) DeleteTrack(id string) (string, error) {
var deletedID string
err := repo.db.Get(&deletedID, "DELETE FROM musictrack WHERE id=$1 RETURNING id", id)
return deletedID, err
}

View file

@ -1,130 +0,0 @@
package account
import (
"arimelody-web/errors"
"arimelody-web/model"
repository "arimelody-web/repository/account"
"arimelody-web/service/validator"
"log"
)
type AccountService struct {
repo repository.AccountRepository
log *log.Logger
}
func NewAccountService(repo repository.AccountRepository, logger *log.Logger) (*AccountService) {
return &AccountService{
repo: repo,
log: logger,
}
}
func (s *AccountService) GetAll() ([]*model.Account, error) {
return s.repo.GetAll()
}
func (s *AccountService) GetCount() (int, error) {
return s.repo.GetCount()
}
func (s *AccountService) GetByID(id string) (*model.Account, error) {
account, err := s.repo.GetByID(id)
if err != nil { return nil, err }
if account == nil { return nil, errors.NewNotExistError("Account does not exist") }
return account, nil
}
func (s *AccountService) GetByUsername(username string) (*model.Account, error) {
account, err := s.repo.GetByUsername(username)
if err != nil { return nil, err }
if account == nil { return nil, errors.NewNotExistError("Account does not exist") }
return account, nil
}
func (s *AccountService) GetByEmail(email string) (*model.Account, error) {
account, err := s.repo.GetByEmail(email)
if err != nil { return nil, err }
if account == nil { return nil, errors.NewNotExistError("Account does not exist") }
return account, nil
}
func (s *AccountService) Create(
username string,
password string,
email *string,
avatarURL *string,
) (string, error) {
if len(username) == 0 { return "", errors.NewValidationError("Username cannot be empty") }
if !validator.ValidateID(username) { return "", errors.NewValidationError("Username contains invalid characters") }
if len(password) == 0 { return "", errors.NewValidationError("Password cannot be empty") }
if email != nil && len(*email) == 0 { return "", errors.NewValidationError("Email cannot be empty") }
var id string
var err error
if id, err = s.repo.Create(username, password, email, avatarURL); err != nil {
return id, err
}
s.log.Printf("Created account '%s' (%s)", username, id)
return id, nil
}
func (s *AccountService) ChangeUsername(id string, username string) error {
if len(username) == 0 { return errors.NewValidationError("Username cannot be empty") }
if !validator.ValidateID(username) { return errors.NewValidationError("Username contains invalid characters") }
if err := s.repo.UpdateUsername(id, username); err != nil { return err }
s.log.Printf("Changed username for %s to '%s'", id, username)
return nil
}
func (s *AccountService) ChangePassword(id string, password string) error {
if len(password) == 0 { return errors.NewValidationError("Password cannot be empty") }
if err := s.repo.UpdatePassword(id, password); err != nil { return err }
s.log.Printf("Changed password for %s", id)
return nil
}
func (s *AccountService) ChangeEmail(id string, email string) error {
if len(email) == 0 { return s.repo.RemoveEmail(id) }
if err := s.repo.UpdateEmail(id, email); err != nil { return err }
s.log.Printf("Changed email for %s to '%s'", id, email)
return nil
}
func (s *AccountService) ChangeAvatarURL(id string, avatarURL string) error {
if len(avatarURL) == 0 { return s.repo.RemoveAvatar(id) }
if err := s.repo.UpdateAvatarURL(id, avatarURL); err != nil { return err }
s.log.Printf("Changed avatar URL for %s to '%s'", id, avatarURL)
return nil
}
func (s *AccountService) IncrementFails(id string) (int, error) {
num, err := s.repo.IncrementFails(id)
if err != nil { return 0, err }
s.log.Printf("Incremented auth failures for account %s (now %d)", id, num)
return num, nil
}
func (s *AccountService) ResetFails(id string) (error) {
if err := s.repo.ResetFails(id); err != nil { return err }
s.log.Printf("Reset auth failures for account %s", id)
return nil
}
func (s *AccountService) Lock(id string) error {
if err := s.repo.SetLocked(id, true); err != nil { return err }
s.log.Printf("Locked account %s", id)
return nil
}
func (s *AccountService) Unlock(id string) error {
if err := s.repo.SetLocked(id, false); err != nil { return err }
s.log.Printf("Unlocked account %s", id)
return nil
}
func (s *AccountService) Delete(id string) error {
deletedID, err := s.repo.Delete(id)
if err != nil { return err }
if deletedID == "" { return errors.NewNotExistError("Account does not exist") }
s.log.Printf("Deleted account %s", id)
return nil
}

View file

@ -1,407 +0,0 @@
package account_test
import (
"arimelody-web/model"
repository "arimelody-web/repository/account"
service "arimelody-web/service/account"
"arimelody-web/errors"
"log"
"os"
"testing"
"gotest.tools/v3/assert"
)
var (
s *service.AccountService
)
func init() {
devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666)
if err != nil { panic(err) }
defer devNullFile.Close()
repo := repository.NewAccountRepositoryMemory(make([]*model.Account, 0))
s = service.NewAccountService(
repo,
log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS),
)
}
// Tests the account lifecycle. Users:
// - [x] can create account
// - [x] can't create duplicate account
// - [x] can change username
// - [x] can change password
// - [x] can change email
// - [x] can change avatar URL
// - [x] can increment auth failures
// - [x] can reset auth failures
// - [x] can lock account
// - [x] can unlock account
// - [x] can delete account
func Test_Account(t *testing.T) {
username := "testificate"
password := "the amazing digital data breach"
email := "goober@arimelody.space"
avatarURL := "/img/account-avatar.webp"
var id string
var err error
t.Run("accounts should start empty", func(t *testing.T) {
t.Run("count is zero", func(t *testing.T) {
if num, err := s.GetCount(); err != nil {
t.Errorf("Failed to get number of accounts: %v", err)
} else {
assert.Equal(t, num, 0)
}
})
t.Run("service returns empty array", func(t *testing.T) {
if accounts, err := s.GetAll(); err != nil {
t.Errorf("Failed to get accounts: %v", err)
} else {
assert.Equal(t, len(accounts), 0)
}
})
})
t.Run("can create account", func(t *testing.T) {
id, err = s.Create(username, password, &email, &avatarURL)
if err != nil {
t.Errorf("Failed to create account: %v", err)
}
t.Run("but not with invalid username", func(t *testing.T) {
if _, err := s.Create("", password, &email, &avatarURL); err == nil {
t.Error("Could create account with invalid username")
} else if !errors.IsValidationError(err) {
t.Error("Error is not validation error")
}
})
t.Run("but not with invalid password", func(t *testing.T) {
if _, err := s.Create("test-username", "", &email, &avatarURL); err == nil {
t.Error("Could create account with invalid password")
} else if !errors.IsValidationError(err) {
t.Error("Error is not validation error")
}
})
t.Run("and fetch by ID", func(t *testing.T) {
account, err := s.GetByID(id)
if err != nil {
t.Errorf("Failed to get account after creation: %v", err)
}
assert.Equal(t, account.Username, username)
assert.Equal(t, account.Password, password)
assert.Equal(t, account.Email.String, email)
assert.Equal(t, account.AvatarURL.String, avatarURL)
assert.Equal(t, account.FailAttempts, 0)
assert.Equal(t, account.Locked, false)
})
t.Run("and fetch by username", func(t *testing.T) {
account, err := s.GetByUsername(username)
if err != nil {
t.Errorf("Failed to get account after creation: %v", err)
}
assert.Equal(t, account.ID, id)
assert.Equal(t, account.Password, password)
assert.Equal(t, account.Email.String, email)
assert.Equal(t, account.AvatarURL.String, avatarURL)
assert.Equal(t, account.FailAttempts, 0)
assert.Equal(t, account.Locked, false)
})
t.Run("and fetch by email", func(t *testing.T) {
account, err := s.GetByEmail(email)
if err != nil {
t.Errorf("Failed to get account after creation: %v", err)
}
assert.Equal(t, account.ID, id)
assert.Equal(t, account.Username, username)
assert.Equal(t, account.Password, password)
assert.Equal(t, account.AvatarURL.String, avatarURL)
assert.Equal(t, account.FailAttempts, 0)
assert.Equal(t, account.Locked, false)
})
})
t.Run("number of accounts should increment", func(t *testing.T) {
t.Run("count is one", func(t *testing.T) {
if num, err := s.GetCount(); err != nil {
t.Errorf("Failed to get number of accounts: %v", err)
} else {
assert.Equal(t, num, 1)
}
})
t.Run("service returns array with one account", func(t *testing.T) {
if accounts, err := s.GetAll(); err != nil {
t.Errorf("Failed to get accounts: %v", err)
} else {
assert.Equal(t, len(accounts), 1)
}
})
})
t.Run("can't create duplicate account", func(t *testing.T) {
_, err := s.Create(username, password, &email, &avatarURL)
if err == nil {
t.Error("Duplicate account was created")
}
})
t.Run("can change username", func(t *testing.T) {
testUsername := "some_other_name"
if err := s.ChangeUsername(id, testUsername); err != nil {
t.Errorf("Failed to change username: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else if account.Username != testUsername {
t.Error("Username did not update")
}
t.Run("but not to an invalid value", func(t *testing.T) {
if err := s.ChangeUsername(id, ""); err == nil {
t.Error("Could change username to invalid value")
} else if !errors.IsValidationError(err) {
t.Error("Error is not validation error")
}
})
})
t.Run("can change password", func(t *testing.T) {
testPassword := "other more different password"
if err := s.ChangePassword(id, testPassword); err != nil {
t.Errorf("Failed to change password: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else if account.Password != testPassword {
t.Error("Password did not update")
}
t.Run("but not to an invalid value", func(t *testing.T) {
if err := s.ChangePassword(id, ""); err == nil {
t.Error("Could change password to invalid value")
} else if !errors.IsValidationError(err) {
t.Error("Error is not validation error")
}
})
})
t.Run("can change email", func(t *testing.T) {
testEmail := "brandnewemail@for.me"
if err := s.ChangeEmail(id, testEmail); err != nil {
t.Errorf("Failed to change email: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else if !account.Email.Valid || account.Email.String != testEmail {
t.Error("Email did not update")
}
})
t.Run("can remove email", func(t *testing.T) {
if err := s.ChangeEmail(id, ""); err != nil {
t.Errorf("Failed to change email: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else if account.Email.Valid || len(account.Email.String) > 0 {
t.Error("Email did not update")
}
})
t.Run("can change avatar URL", func(t *testing.T) {
testAvatarURL := "/img/some-other-avatar.webp"
if err := s.ChangeAvatarURL(id, testAvatarURL); err != nil {
t.Errorf("Failed to change avatar URL: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else if !account.AvatarURL.Valid || account.AvatarURL.String != testAvatarURL {
t.Error("Avatar URL did not update")
}
})
t.Run("can remove avatar URL", func(t *testing.T) {
if err := s.ChangeAvatarURL(id, ""); err != nil {
t.Errorf("Failed to change avatar URL: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else if account.AvatarURL.Valid || len(account.AvatarURL.String) > 0 {
t.Error("Avatar URL did not update")
}
})
t.Run("can increment auth failures", func(t *testing.T) {
if num, err := s.IncrementFails(id); err != nil {
t.Errorf("Failed to increment account auth failures: %v", err)
} else {
assert.Equal(t, num, 1)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else {
assert.Equal(t, account.FailAttempts, 1)
}
})
t.Run("can reset auth failures", func(t *testing.T) {
if err := s.ResetFails(id); err != nil {
t.Errorf("Failed to reset account auth failures: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else {
assert.Equal(t, account.FailAttempts, 0)
}
})
t.Run("can lock account", func(t *testing.T) {
if err := s.Lock(id); err != nil {
t.Errorf("Failed to lock account: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else {
assert.Equal(t, account.Locked, true)
}
})
t.Run("can unlock account", func(t *testing.T) {
if err := s.Unlock(id); err != nil {
t.Errorf("Failed to unlock account: %v", err)
}
if account, err := s.GetByID(id); err != nil {
t.Errorf("Failed to get account: %v", err)
} else if account == nil {
t.Error("Account is nil after update")
} else {
assert.Equal(t, account.Locked, false)
}
})
t.Run("can delete account", func(t *testing.T) {
if err = s.Delete(id); err != nil {
t.Errorf("Failed to delete account: %v", err)
}
if account, err := s.GetByID(id); err != nil {
if !errors.IsNotExistError(err) {
t.Errorf("Failed to get account after deletion: %v", err)
}
} else if account != nil {
t.Error("Account still exists after deletion")
}
})
t.Run("can't create an account with invalid", func(t *testing.T) {
t.Run("username", func(t *testing.T) {
if _, err := s.Create("", password, &email, &avatarURL); err == nil {
t.Error("Could create account with empty username")
}
})
t.Run("password", func(t *testing.T) {
if _, err := s.Create(username, "", &email, &avatarURL); err == nil {
t.Error("Could create account with empty password")
}
})
t.Run("email", func(t *testing.T) {
testEmail := ""
if _, err := s.Create(username, password, &testEmail, &avatarURL); err == nil {
t.Error("Could create account with empty (non-nil) email")
}
})
})
t.Run("can't fetch account that doesn't exist", func(t *testing.T) {
t.Run("by ID", func(t *testing.T) {
if account, err := s.GetByID("adsginh534g9405gmb40i9bm"); err != nil {
if !errors.IsNotExistError(err) { t.Errorf("Failed to get account: %v", err) }
} else if account != nil {
t.Error("Could fetch non-existent account")
}
})
t.Run("by username", func(t *testing.T) {
if account, err := s.GetByUsername("adsginh534g9405gmb40i9bm"); err != nil {
if !errors.IsNotExistError(err) { t.Errorf("Failed to get account: %v", err) }
} else if account != nil {
t.Error("Could fetch non-existent account")
}
})
t.Run("email", func(t *testing.T) {
if account, err := s.GetByEmail("adsginh534g9405gmb40i9bm"); err != nil {
if !errors.IsNotExistError(err) { t.Errorf("Failed to get account: %v", err) }
} else if account != nil {
t.Error("Could fetch non-existent account")
}
})
})
t.Run("can't update account that doesn't exist", func(t *testing.T) {
garbageAccountID := "adsginh534g9405gmb40i9bm"
t.Run("username", func(t *testing.T) {
if err := s.ChangeUsername(garbageAccountID, "some-username"); err == nil {
t.Error("Could update non-existent account's username")
}
})
t.Run("password", func(t *testing.T) {
if err := s.ChangePassword(garbageAccountID, "some-password"); err == nil {
t.Error("Could update non-existent account's password")
}
})
t.Run("email", func(t *testing.T) {
if err := s.ChangeEmail(garbageAccountID, "some-email@real.gov"); err == nil {
t.Error("Could update non-existent account's email")
}
})
t.Run("avatar URL", func(t *testing.T) {
if err := s.ChangeAvatarURL(garbageAccountID, "/img/null.webp"); err == nil {
t.Error("Could update non-existent account's avatar URL")
}
})
})
}

View file

@ -1,61 +0,0 @@
package log
import (
"arimelody-web/model"
repository "arimelody-web/repository/log"
"arimelody-web/errors"
"fmt"
"log"
"time"
)
type LogService struct {
repo repository.LogRepository
log *log.Logger
}
func NewLogService(repo repository.LogRepository, logger *log.Logger) *LogService {
return &LogService{
repo: repo,
log: logger,
}
}
const DEFAULT_LOG_PAGE_LENGTH = 25
func (s *LogService) Info(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
s.log.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString)
if err := s.repo.Create(model.LEVEL_INFO, logType, logString); err != nil {
log.Printf("WARN: Failed to push log to database: %v\n", err)
}
}
func (s *LogService) Warn(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
log.Printf("[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString)
if err := s.repo.Create(model.LEVEL_INFO, logType, logString); err != nil {
log.Printf("WARN: Failed to push log to database: %v\n", err)
}
}
func (s *LogService) Fetch(id string) (*model.Log, error) {
log, err := s.repo.Get(id)
if err != nil { return nil, err }
if log == nil { return nil, errors.NewNotExistError("Log does not exist") }
return log, nil
}
func (s *LogService) Search(
levelFilters []model.LogLevel,
typeFilters []string,
content string,
limit int,
offset int,
) ([]*model.Log, error) {
return s.repo.Search(levelFilters, typeFilters, content, limit, offset)
}

View file

@ -1,82 +0,0 @@
package music
import (
"arimelody-web/errors"
"arimelody-web/model"
"arimelody-web/service/validator"
)
func (s *MusicService) GetAllArtists() ([]*model.Artist, error) {
return s.repo.GetAllArtists()
}
func (s *MusicService) GetArtistCount() (int, error) {
return s.repo.GetArtistCount()
}
func (s *MusicService) GetArtistByID(id string) (*model.Artist, error) {
artist, err := s.repo.GetArtistByID(id)
if err != nil { return nil, err }
if artist == nil { return nil, errors.NewNotExistError("Artist does not exist") }
return artist, nil
}
func (s *MusicService) GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) {
return s.repo.GetArtistsNotOnRelease(releaseID)
}
func (s *MusicService) GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) {
return s.repo.GetArtistCredits(artistID, showHidden)
}
func (s *MusicService) CreateArtist(
id string,
name string,
website string,
avatar string,
) error {
if len(id) == 0 { return errors.NewValidationError("Artist ID cannot be empty") }
if !validator.ValidateID(id) { return errors.NewValidationError("Artist ID contains invalid characters") }
if len(name) == 0 { return errors.NewValidationError("Artist name cannot be empty") }
if err := s.repo.CreateArtist(id, name, website, avatar); err != nil { return err }
s.log.Printf("Created new artist '%s' (%s)", name, id)
return nil
}
func (s *MusicService) UpdateArtist(artist *model.Artist) error {
if len(artist.ID) == 0 { return errors.NewValidationError("Artist ID cannot be empty") }
if !validator.ValidateID(artist.ID) { return errors.NewValidationError("Artist ID contains invalid characters") }
if len(artist.Name) == 0 { return errors.NewValidationError("Artist name cannot be empty") }
if err := s.repo.UpdateArtist(artist); err != nil { return err }
s.log.Printf("Updated artist %s", artist.ID)
return nil
}
func (s *MusicService) UpdateArtistID(oldID string, newID string) error {
if len(newID) == 0 { return errors.NewValidationError("Artist ID cannot be empty") }
if !validator.ValidateID(newID) { return errors.NewValidationError("Artist ID contains invalid characters") }
if err := s.repo.UpdateArtistID(oldID, newID); err != nil { return err }
s.log.Printf("Updated artist ID %s to %s", oldID, newID)
return nil
}
func (s *MusicService) UpdateArtistName(id string, name string) error {
if len(name) == 0 { return errors.NewValidationError("Artist name cannot be empty") }
if err := s.repo.UpdateArtistName(id, name); err != nil { return err }
s.log.Printf("Updated artist %s name to %s", id, name)
return nil
}
func (s *MusicService) UpdateArtistWebsite(id string, website string) error {
if err := s.repo.UpdateArtistWebsite(id, website); err != nil { return err }
s.log.Printf("Updated artist %s website to %s", id, website)
return nil
}
func (s *MusicService) UpdateArtistAvatar(id string, avatar string) error {
if err := s.repo.UpdateArtistAvatar(id, avatar); err != nil { return err }
s.log.Printf("Updated artist %s avatar to %s", id, avatar)
return nil
}
func (s *MusicService) DeleteArtist(id string) error {
deletedID, err := s.repo.DeleteArtist(id)
if err != nil { return err }
if deletedID == "" { return errors.NewNotExistError("Artist does not exist") }
s.log.Printf("Deleted artist %s", id)
return nil
}

View file

@ -1,225 +0,0 @@
package music_test
import (
"arimelody-web/errors"
"arimelody-web/model"
repository "arimelody-web/repository/music"
service "arimelody-web/service/music"
"log"
"os"
"slices"
"testing"
"time"
"gotest.tools/v3/assert"
)
func Test_Artist(t *testing.T) {
devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666)
if err != nil { panic(err) }
defer devNullFile.Close()
repo := repository.NewMusicRepositoryMemory(
make([]*model.Artist, 0),
make([]*model.Release, 0),
make([]*model.Track, 0),
)
s = service.NewMusicService(
repo,
log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS),
)
id := "cool-artist"
name := "Cool Artist"
website := "artist.arimelody.space"
avatarURL := "/img/cool-artist.webp"
t.Run("artists should start empty", func(t *testing.T) {
t.Run("count is zero", func(t *testing.T) {
if num, err := s.GetArtistCount(); err != nil {
t.Errorf("Failed to get number of artists: %v", err)
} else {
assert.Equal(t, num, 0)
}
})
t.Run("service returns empty array", func(t *testing.T) {
if artists, err := s.GetAllArtists(); err != nil {
t.Errorf("Failed to get artists: %v", err)
} else {
assert.Equal(t, len(artists), 0)
}
})
})
t.Run("can create artist", func(t *testing.T) {
if err := s.CreateArtist(id, name, website, avatarURL); err != nil {
t.Errorf("Failed to create artist: %v", err)
}
t.Run("but not with an invalid ID", func(t *testing.T) {
if err := s.CreateArtist("", name, website, avatarURL); err == nil {
t.Error("Created artist with invalid ID")
}
})
t.Run("but not with an invalid name", func(t *testing.T) {
if err := s.CreateArtist("test-artist", "", website, avatarURL); err == nil {
t.Error("Created artist with invalid name")
}
})
t.Run("and retrieve it", func(t *testing.T) {
repoArtist, err := s.GetArtistByID(id)
if err != nil { t.Errorf("Failed to get artist: %v", err) }
assert.Equal(t, repoArtist.ID, id)
assert.Equal(t, repoArtist.Name, name)
assert.Equal(t, repoArtist.Website, website)
assert.Equal(t, repoArtist.Avatar, avatarURL)
})
})
t.Run("number of artists should increment", func(t *testing.T) {
t.Run("count is one", func(t *testing.T) {
if num, err := s.GetArtistCount(); err != nil {
t.Errorf("Failed to get number of artists: %v", err)
} else {
assert.Equal(t, num, 1)
}
})
t.Run("service returns array with one artist", func(t *testing.T) {
if artists, err := s.GetAllArtists(); err != nil {
t.Errorf("Failed to get artists: %v", err)
} else {
assert.Equal(t, len(artists), 1)
}
})
})
releaseID := "test-release"
t.Run("can get artists not on a release", func(t *testing.T) {
if err := s.CreateRelease(releaseID, "test", string(model.Single), time.Now(), ""); err != nil {
t.Errorf("Failed to create release: %v", err)
}
artists, err := s.GetArtistsNotOnRelease(releaseID)
if err != nil { t.Errorf("Failed to get artists: %v", err) }
assert.Assert(t, slices.ContainsFunc(artists, func(artist *model.Artist) bool {
return artist.ID == id
}))
})
credit := &model.Credit{
Artist: &model.Artist{ ID: id, Name: name, Website: website, Avatar: avatarURL },
Role: "did a lot of stuff",
Primary: true,
}
t.Run("can get artist credits", func(t *testing.T) {
if err := s.UpdateReleaseCredits(releaseID, []*model.Credit{ credit }); err != nil {
t.Errorf("Failed to update release credits: %v", err)
}
credits, err := s.GetArtistCredits(id, true)
if err != nil { t.Errorf("Failed to get credits: %v", err) }
index := slices.IndexFunc(credits, func(credit *model.Credit) bool {
return credit.Artist.ID == id
})
assert.Assert(t, index != -1)
assert.Equal(t, credits[index].Artist.ID, id)
assert.Equal(t, credits[index].Artist.Name, name)
assert.Equal(t, credits[index].Artist.Website, website)
assert.Equal(t, credits[index].Artist.Avatar, avatarURL)
assert.Equal(t, credits[index].Role, credit.Role)
assert.Equal(t, credits[index].Primary, credit.Primary)
})
t.Run("can update artist", func(t *testing.T) {
testName := "this name is only temporary"
testWebsite := "https://test.example.org"
testAvatar := "/img/test-avatar.webp"
if err := s.UpdateArtist(&model.Artist{
ID: id,
Name: testName,
Website: testWebsite,
Avatar: testAvatar,
}); err != nil {
t.Errorf("Failed to update artist: %v", err)
}
artist, err := s.GetArtistByID(id)
if err != nil { t.Errorf("Failed to get artist: %v", err) }
assert.Equal(t, artist.Name, testName)
assert.Equal(t, artist.Website, testWebsite)
assert.Equal(t, artist.GetAvatar(), testAvatar)
t.Run("but not with invalid name", func(t *testing.T) {
if err := s.UpdateArtist(&model.Artist{
ID: id,
Name: "",
}); err == nil {
t.Errorf("Updated artist name to invalid value")
}
})
})
t.Run("can update artist name", func(t *testing.T) {
if err := s.UpdateArtistName(id, name); err != nil {
t.Errorf("Failed to update artist: %v", err)
}
artist, err := s.GetArtistByID(id)
if err != nil { t.Errorf("Failed to get artist: %v", err) }
assert.Equal(t, artist.Name, name)
t.Run("but not with invalid value", func(t *testing.T) {
if err := s.UpdateArtistName(id, ""); err == nil {
t.Errorf("Updated artist name to invalid value")
}
})
})
t.Run("can update artist website", func(t *testing.T) {
if err := s.UpdateArtistWebsite(id, website); err != nil {
t.Errorf("Failed to update artist: %v", err)
}
artist, err := s.GetArtistByID(id)
if err != nil { t.Errorf("Failed to get artist: %v", err) }
assert.Equal(t, artist.Website, website)
})
t.Run("can update artist avatar", func(t *testing.T) {
if err := s.UpdateArtistAvatar(id, ""); err != nil {
t.Errorf("Failed to update artist: %v", err)
}
artist, err := s.GetArtistByID(id)
if err != nil { t.Errorf("Failed to get artist: %v", err) }
assert.Equal(t, artist.GetAvatar(), model.DEFAULT_AVATAR_URL)
})
t.Run("can delete artist", func(t *testing.T) {
if err := s.DeleteArtist(id); err != nil {
t.Errorf("Failed to delete artist: %v", err)
}
t.Run("no longer exists", func(t *testing.T) {
if artist, err := s.GetArtistByID(id); err == nil {
t.Error("No error getting artist")
} else if artist != nil {
t.Error("Artist with this ID still exists")
}
})
t.Run("but not one that doesn't exist", func(t *testing.T) {
if err := s.DeleteArtist("some-garbage-id"); err == nil {
if !errors.IsNotExistError(err) {
t.Errorf("Failed to delete artist: %v", err)
}
}
})
})
}

View file

@ -1,18 +0,0 @@
package music
import (
repository "arimelody-web/repository/music"
"log"
)
type MusicService struct {
repo repository.MusicRepository
log *log.Logger
}
func NewMusicService(repo repository.MusicRepository, logger *log.Logger) *MusicService {
return &MusicService{
repo: repo,
log: logger,
}
}

View file

@ -1,9 +0,0 @@
package music_test
import (
service "arimelody-web/service/music"
)
var (
s *service.MusicService
)

View file

@ -1,180 +0,0 @@
package music
import (
"arimelody-web/errors"
"arimelody-web/model"
"arimelody-web/service/validator"
"fmt"
"time"
)
// Hydrates a release with its additional data (credits, tracks, links)
func (s *MusicService) fillRelease(release *model.Release) error {
credits, err := s.GetReleaseCredits(release.ID)
if err != nil { return fmt.Errorf("credits: %s", err) }
release.Credits = append(release.Credits, credits...)
tracks, err := s.GetReleaseTracks(release.ID)
if err != nil { return fmt.Errorf("tracks: %s", err) }
release.Tracks = append(release.Tracks, tracks...)
links, err := s.GetReleaseLinks(release.ID)
if err != nil { return fmt.Errorf("links: %s", err) }
release.Links = append(release.Links, links...)
return nil
}
func (s *MusicService) GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) {
return s.repo.GetAllReleases(onlyVisible, limit)
}
func (s *MusicService) GetAllFullReleases(onlyVisible bool, limit int) ([]*model.Release, error) {
releases, err := s.repo.GetAllReleases(onlyVisible, limit)
if err != nil { return nil, err }
for _, release := range releases {
if err := s.fillRelease(release); err != nil { return nil, err }
}
return releases, nil
}
func (s *MusicService) GetReleaseCount(onlyVisible bool) (int, error) {
return s.repo.GetReleaseCount(onlyVisible)
}
func (s *MusicService) GetReleaseByID(id string) (*model.Release, error) {
release, err := s.repo.GetReleaseByID(id)
if err != nil { return nil, err }
if release == nil { return nil, errors.NewNotExistError("Release does not exist") }
return release, nil
}
func (s *MusicService) GetFullReleaseByID(id string) (*model.Release, error) {
release, err := s.GetReleaseByID(id)
if err != nil { return nil, err }
if err := s.fillRelease(release); err != nil { return nil, err }
return release, nil
}
func (s *MusicService) GetReleaseTracks(id string) ([]*model.Track, error) {
return s.repo.GetReleaseTracks(id)
}
func (s *MusicService) GetReleaseCredits(id string) ([]*model.Credit, error) {
return s.repo.GetReleaseCredits(id)
}
func (s *MusicService) GetReleaseLinks(id string) ([]*model.Link, error) {
return s.repo.GetReleaseLinks(id)
}
func (s *MusicService) CreateRelease(
id string,
title string,
releaseType string,
releaseDate time.Time,
artworkURL string,
) error {
if len(id) == 0 { return errors.NewValidationError("Release ID cannot be empty") }
if !validator.ValidateID(id) { return errors.NewValidationError("Release ID contains invalid characters") }
if len(title) == 0 { return errors.NewValidationError("Release title cannot be empty") }
validReleaseType, ok := model.ValidReleaseType(releaseType)
if !ok { return errors.NewValidationError("Invalid release type") }
if err := s.repo.CreateRelease(id, title, validReleaseType, releaseDate, artworkURL); err != nil { return err }
s.log.Printf("Created new release '%s' (%s)", title, id)
return nil
}
func (s *MusicService) UpdateRelease(release *model.Release) error {
if len(release.ID) == 0 { return errors.NewValidationError("Release ID cannot be empty") }
if !validator.ValidateID(release.ID) { return errors.NewValidationError("Release ID contains invalid characters") }
if len(release.Title) == 0 { return errors.NewValidationError("Release title cannot be empty") }
if err := s.repo.UpdateRelease(release); err != nil { return err }
s.log.Printf("Updated release '%s' (%s)", release.Title, release.ID)
return nil
}
func (s *MusicService) UpdateReleaseID(oldID string, newID string) error {
if len(newID) == 0 { return errors.NewValidationError("Release ID cannot be empty") }
if !validator.ValidateID(newID) { return errors.NewValidationError("Release ID contains invalid characters") }
if err := s.repo.UpdateReleaseID(oldID, newID); err != nil { return err }
s.log.Printf("Updated release ID %s to %s", oldID, newID)
return nil
}
func (s *MusicService) UpdateReleaseVisibility(id string, visible bool) error {
if err := s.repo.UpdateReleaseVisibility(id, visible); err != nil { return err }
s.log.Printf("Updated release '%s' visibility to %t", id, visible)
return nil
}
func (s *MusicService) UpdateReleaseTitle(id string, title string) error {
if len(title) == 0 { return errors.NewValidationError("Release title cannot be empty") }
if err := s.repo.UpdateReleaseTitle(id, title); err != nil { return err }
s.log.Printf("Updated release '%s' title to %s", id, title)
return nil
}
func (s *MusicService) UpdateReleaseDescription(id string, description string) error {
if err := s.repo.UpdateReleaseDescription(id, description); err != nil { return err }
s.log.Printf("Updated release '%s' description to %s", id, description)
return nil
}
func (s *MusicService) UpdateReleaseType(id string, releaseType string) error {
validReleaseType, ok := model.ValidReleaseType(releaseType)
if !ok { return errors.NewValidationError("Invalid release type") }
if err := s.repo.UpdateReleaseType(id, validReleaseType); err != nil { return err }
s.log.Printf("Updated release '%s' type to %s", id, releaseType)
return nil
}
func (s *MusicService) UpdateReleaseDate(id string, releaseDate time.Time) error {
if err := s.repo.UpdateReleaseDate(id, releaseDate); err != nil { return err }
s.log.Printf("Updated release '%s' date to %s", id, releaseDate.Format(time.RFC3339))
return nil
}
func (s *MusicService) UpdateReleaseArtwork(id string, artwork string) error {
if err := s.repo.UpdateReleaseArtwork(id, artwork); err != nil { return err }
s.log.Printf("Updated release '%s' artwork to %s", id, artwork)
return nil
}
func (s *MusicService) UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error {
if err := s.repo.UpdateReleaseBuyInfo(id, buyName, buyLink); err != nil { return err }
s.log.Printf("Updated release '%s' buy info (name='%s', link='%s')", id, buyName, buyLink)
return nil
}
func (s *MusicService) UpdateReleaseCopyright(id string, copyright string, url string) error {
if err := s.repo.UpdateReleaseCopyright(id, copyright, url); err != nil { return err }
s.log.Printf("Updated release '%s' copyright (copyright='%s', url='%s')", id, copyright, url)
return nil
}
func (s *MusicService) UpdateReleaseTracks(id string, newTrackIDs []string) error {
for _, id := range newTrackIDs {
if len(id) == 0 { return errors.NewValidationError("Track IDs cannot be empty") }
}
if err := s.repo.UpdateReleaseTracks(id, newTrackIDs); err != nil { return err }
s.log.Printf("Updated release '%s' tracks (%d tracks)", id, len(newTrackIDs))
return nil
}
func (s *MusicService) UpdateReleaseCredits(id string, newCredits []*model.Credit) error {
if err := s.repo.UpdateReleaseCredits(id, newCredits); err != nil { return err }
s.log.Printf("Updated release '%s' credits (%d credits)", id, len(newCredits))
return nil
}
func (s *MusicService) UpdateReleaseLinks(id string, newLinks []*model.Link) error {
for _, link := range newLinks {
if len(link.Name) == 0 { return errors.NewValidationError("Link names cannot be empty") }
if len(link.URL) == 0 { return errors.NewValidationError("Link URLs cannot be empty") }
}
if err := s.repo.UpdateReleaseLinks(id, newLinks); err != nil { return err }
s.log.Printf("Updated release '%s' links (%d links)", id, len(newLinks))
return nil
}
func (s *MusicService) DeleteRelease(id string) error {
deletedID, err := s.repo.DeleteRelease(id)
if err != nil { return err }
if deletedID == "" { return errors.NewNotExistError("Release does not exist") }
s.log.Printf("Deleted release '%s'", id)
return nil
}

View file

@ -1,374 +0,0 @@
package music_test
import (
"arimelody-web/errors"
"arimelody-web/model"
repository "arimelody-web/repository/music"
service "arimelody-web/service/music"
"log"
"os"
"slices"
"testing"
"time"
"gotest.tools/v3/assert"
)
func Test_Release(t *testing.T) {
devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666)
if err != nil { panic(err) }
defer devNullFile.Close()
repo := repository.NewMusicRepositoryMemory(
make([]*model.Artist, 0),
make([]*model.Release, 0),
make([]*model.Track, 0),
)
s = service.NewMusicService(
repo,
log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS),
)
id := "cool-release"
title := "Cool Release"
releaseType := model.Album
releaseDate := time.Now()
artworkURL := "/img/some-cool-artwork.webp"
t.Run("releases should start empty", func(t *testing.T) {
t.Run("count is zero", func(t *testing.T) {
if num, err := s.GetReleaseCount(false); err != nil {
t.Errorf("Failed to get number of releases: %v", err)
} else {
assert.Equal(t, num, 0)
}
})
t.Run("service returns empty array", func(t *testing.T) {
if releases, err := s.GetAllReleases(false, 0); err != nil {
t.Errorf("Failed to get releases: %v", err)
} else {
assert.Equal(t, len(releases), 0)
}
})
})
t.Run("can create release", func(t *testing.T) {
if err := s.CreateRelease(id, title, string(releaseType), releaseDate, artworkURL); err != nil {
t.Errorf("Failed to create release: %v", err)
}
t.Run("but not with an invalid ID", func(t *testing.T) {
if err := s.CreateRelease("", title, string(releaseType), releaseDate, artworkURL); err == nil {
t.Error("Created release with invalid ID")
}
})
t.Run("but not with an invalid title", func(t *testing.T) {
if err := s.CreateRelease("test-release", "", string(releaseType), releaseDate, artworkURL); err == nil {
t.Error("Created release with invalid title")
}
})
t.Run("but not with an invalid type", func(t *testing.T) {
if err := s.CreateRelease("test-release", "", "garbage-type", releaseDate, artworkURL); err == nil {
t.Error("Created release with invalid type")
}
})
t.Run("and retrieve it", func(t *testing.T) {
repoRelease, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if repoRelease == nil { t.Error("Release does not exist") }
assert.Equal(t, repoRelease.ID, id)
assert.Equal(t, repoRelease.Title, title)
assert.Equal(t, repoRelease.ReleaseType, releaseType)
assert.Equal(t, repoRelease.ReleaseDate, releaseDate)
assert.Equal(t, repoRelease.GetArtwork(), artworkURL)
})
t.Run("should not be visible by default", func(t *testing.T) {
releases, err := s.GetAllReleases(true, 0)
if err != nil { t.Errorf("Failed to get releases: %v", err) }
assert.Equal(t, slices.ContainsFunc(releases, func(release *model.Release) bool {
return release.ID == id
}), false)
})
})
t.Run("number of releases should increment", func(t *testing.T) {
t.Run("count is one", func(t *testing.T) {
if num, err := s.GetReleaseCount(false); err != nil {
t.Errorf("Failed to get number of releases: %v", err)
} else {
assert.Equal(t, num, 1)
}
})
t.Run("service returns array with one release", func(t *testing.T) {
if releases, err := s.GetAllReleases(false, 0); err != nil {
t.Errorf("Failed to get releases: %v", err)
} else {
assert.Equal(t, len(releases), 1)
}
})
})
t.Run("can update release", func(t *testing.T) {
testTitle := "this title is only temporary"
testReleaseType := model.Compilation
testReleaseDate := time.Now().AddDate(0, 0, 10)
testArtworkURL := "/img/test-artwork.webp"
if err := s.UpdateRelease(&model.Release{
ID: id,
Title: testTitle,
ReleaseType: testReleaseType,
ReleaseDate: testReleaseDate,
Artwork: testArtworkURL,
}); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.Title, testTitle)
assert.Equal(t, release.ReleaseType, testReleaseType)
assert.Equal(t, release.ReleaseDate, testReleaseDate)
assert.Equal(t, release.GetArtwork(), testArtworkURL)
t.Run("but not with invalid title", func(t *testing.T) {
if err := s.UpdateRelease(&model.Release{
ID: id,
Title: "",
}); err == nil {
t.Errorf("Updated release title to invalid value")
}
})
})
t.Run("can update visibility", func(t *testing.T) {
if err := s.UpdateReleaseVisibility(id, true); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.Visible, true)
t.Run("should be visible after updating", func(t *testing.T) {
releases, err := s.GetAllReleases(true, 0)
if err != nil { t.Errorf("Failed to get releases: %v", err) }
assert.Equal(t, slices.ContainsFunc(releases, func(release *model.Release) bool {
return release.ID == id
}), true)
})
})
t.Run("can update title", func(t *testing.T) {
if err := s.UpdateReleaseTitle(id, title); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.Title, title)
t.Run("but not with invalid value", func(t *testing.T) {
if err := s.UpdateReleaseTitle(id, ""); err == nil {
t.Errorf("Updated release title to invalid value")
}
})
})
t.Run("can update description", func(t *testing.T) {
testDescription := "an incredible and thought-provoking description"
if err := s.UpdateReleaseDescription(id, testDescription); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.Description, testDescription)
})
t.Run("can update type", func(t *testing.T) {
testType := model.EP
if err := s.UpdateReleaseType(id, string(testType)); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.ReleaseType, testType)
releaseType = testType
t.Run("but not with invalid value", func(t *testing.T) {
if err := s.UpdateReleaseType(id, "garbage-type"); err == nil {
t.Errorf("Updated release type to invalid value")
}
})
})
t.Run("can update date", func(t *testing.T) {
testDate := time.Now().Add(time.Hour * 24)
if err := s.UpdateReleaseDate(id, testDate); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.ReleaseDate, testDate)
releaseDate = testDate
})
t.Run("can update artwork", func(t *testing.T) {
if err := s.UpdateReleaseArtwork(id, ""); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.GetArtwork(), model.DEFAULT_RELEASE_ARTWORK_URL)
})
t.Run("can update buy info", func(t *testing.T) {
testBuyName := "get it now!!!"
testBuyLink := "https://arimelody.space/music"
if err := s.UpdateReleaseBuyInfo(id, testBuyName, testBuyLink); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.Buyname, testBuyName)
assert.Equal(t, release.Buylink, testBuyLink)
})
t.Run("can update copyright info", func(t *testing.T) {
testCopyright := "CC BY-SA 4.0"
testCopyrightURL := "https://creativecommons.org/licenses/by-sa/4.0/"
if err := s.UpdateReleaseCopyright(id, testCopyright, testCopyrightURL); err != nil {
t.Errorf("Failed to update release: %v", err)
}
release, err := s.GetReleaseByID(id)
if err != nil { t.Errorf("Failed to get release: %v", err) }
if release == nil { t.Error("Release does not exist after update") }
assert.Equal(t, release.Copyright, testCopyright)
assert.Equal(t, release.CopyrightURL, testCopyrightURL)
})
track := &model.Track{
Title: "test track",
Description: "average description",
Lyrics: "some lyrics",
}
t.Run("can update tracks", func(t *testing.T) {
if trackID, err := s.CreateTrack(track.Title, track.Description, track.Lyrics, ""); err != nil {
t.Errorf("Failed to create track: %v", err)
} else { track.ID = trackID }
if err := s.UpdateReleaseTracks(id, []string{ track.ID }); err != nil {
t.Errorf("Failed to update release tracks: %v", err)
}
tracks, err := s.GetReleaseTracks(id)
if err != nil { t.Errorf("Failed to get tracks: %v", err) }
index := slices.IndexFunc(tracks, func(repoTrack *model.Track) bool {
return repoTrack.ID == track.ID
})
assert.Assert(t, index != -1)
assert.Equal(t, tracks[index].Title, track.Title)
assert.Equal(t, tracks[index].Description, track.Description)
assert.Equal(t, tracks[index].Lyrics, track.Lyrics)
})
t.Run("can update credits", func(t *testing.T) {
artist := &model.Artist{ ID: id, Name: title, Website: "", Avatar: "" }
if err := s.CreateArtist(artist.ID, artist.Name, artist.Website, artist.Avatar); err != nil {
t.Errorf("Failed to create artist: %v", err)
}
credit := &model.Credit{
Artist: artist,
Role: "did a lot of stuff",
Primary: true,
}
if err := s.UpdateReleaseCredits(id, []*model.Credit{ credit }); err != nil {
t.Errorf("Failed to update release credits: %v", err)
}
credits, err := s.GetReleaseCredits(id)
if err != nil { t.Errorf("Failed to get credits: %v", err) }
assert.Equal(t, len(credits), 1)
index := slices.IndexFunc(credits, func(repoCredit *model.Credit) bool {
return repoCredit.Artist.ID == credit.Artist.ID
})
assert.Assert(t, index != -1)
assert.Equal(t, credits[index].Release.ID, id)
assert.Equal(t, credits[index].Release.Title, title)
assert.Equal(t, credits[index].Release.ReleaseType, releaseType)
assert.Equal(t, credits[index].Release.ReleaseDate, releaseDate)
assert.Equal(t, credits[index].Release.GetArtwork(), model.DEFAULT_RELEASE_ARTWORK_URL)
assert.Equal(t, credits[index].Artist.ID, credit.Artist.ID)
assert.Equal(t, credits[index].Artist.Name, credit.Artist.Name)
assert.Equal(t, credits[index].Artist.Website, credit.Artist.Website)
assert.Equal(t, credits[index].Artist.GetAvatar(), credit.Artist.GetAvatar())
assert.Equal(t, credits[index].Role, credit.Role)
assert.Equal(t, credits[index].Primary, credit.Primary)
})
t.Run("can update links", func(t *testing.T) {
link := &model.Link{
Name: "awesome link you should totally go here",
URL: "https://arimelody.space",
}
if err := s.UpdateReleaseLinks(id, []*model.Link{ link }); err != nil {
t.Errorf("Failed to update release tracks: %v", err)
}
links, err := s.GetReleaseLinks(id)
if err != nil { t.Errorf("Failed to get tracks: %v", err) }
index := slices.IndexFunc(links, func(repoLink *model.Link) bool {
return repoLink.Name == link.Name
})
assert.Assert(t, index != -1)
assert.Equal(t, links[index].URL, link.URL)
})
t.Run("can delete release", func(t *testing.T) {
if err := s.DeleteRelease(id); err != nil {
t.Errorf("Failed to delete release: %v", err)
}
t.Run("no longer exists", func(t *testing.T) {
if release, err := s.GetReleaseByID(id); err == nil {
t.Error("No error getting release")
} else if release != nil {
t.Error("Release with this ID still exists")
}
})
t.Run("but not one that doesn't exist", func(t *testing.T) {
if err := s.DeleteRelease("some-garbage-id"); err == nil {
if !errors.IsNotExistError(err) {
t.Errorf("Failed to delete release: %v", err)
}
}
})
})
}

View file

@ -1,81 +0,0 @@
package music
import (
"arimelody-web/model"
"arimelody-web/errors"
)
func (s *MusicService) GetAllTracks() ([]*model.Track, error) {
return s.repo.GetAllTracks()
}
func (s *MusicService) GetTrackCount() (int, error) {
return s.repo.GetTrackCount()
}
func (s *MusicService) GetTrackByID(id string) (*model.Track, error) {
track, err := s.repo.GetTrackByID(id)
if err != nil { return nil, err }
if track == nil { return nil, errors.NewNotExistError("Track does not exist") }
return track, nil
}
func (s *MusicService) GetOrphanTracks() ([]*model.Track, error) {
return s.repo.GetOrphanTracks()
}
func (s *MusicService) GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) {
return s.repo.GetTracksNotOnRelease(releaseID)
}
func (s *MusicService) GetTrackReleases(trackID string) ([]*model.Release, error) {
return s.repo.GetTrackReleases(trackID)
}
func (s *MusicService) GetTrackFullReleases(trackID string) ([]*model.Release, error) {
releases, err := s.GetTrackReleases(trackID)
if err != nil { return nil, err }
for _, release := range releases {
if err := s.fillRelease(release); err != nil { return nil, err }
}
return releases, nil
}
func (s *MusicService) CreateTrack(title string, description string, lyrics string, previewURL string) (string, error) {
if len(title) == 0 { return "", errors.NewValidationError("Track title cannot be empty") }
id, err := s.repo.CreateTrack(title, description, lyrics, previewURL)
if err != nil { return "", err }
s.log.Printf("Created track '%s' (%s)", title, id)
return id, nil
}
func (s *MusicService) UpdateTrack(track *model.Track) error {
if len(track.Title) == 0 { return errors.NewValidationError("Track title cannot be empty") }
if err := s.repo.UpdateTrack(track); err != nil { return err }
s.log.Printf("Updated track '%s' (%s)", track.Title, track.ID)
return nil
}
func (s *MusicService) UpdateTrackTitle(id string, title string) error {
if len(title) == 0 { return errors.NewValidationError("Track title cannot be empty") }
if err := s.repo.UpdateTrackTitle(id, title); err != nil { return err }
s.log.Printf("Updated track %s title to '%s'", id, title)
return nil
}
func (s *MusicService) UpdateTrackDescription(id string, description string) error {
if err := s.repo.UpdateTrackDescription(id, description); err != nil { return err }
s.log.Printf("Updated track %s description to '%s'", id, description)
return nil
}
func (s *MusicService) UpdateTrackLyrics(id string, lyrics string) error {
if err := s.repo.UpdateTrackLyrics(id, lyrics); err != nil { return err }
s.log.Printf("Updated track %s lyrics to '%s'", id, lyrics)
return nil
}
func (s *MusicService) UpdateTrackPreviewURL(id string, previewURL string) error {
if err := s.repo.UpdateTrackPreviewURL(id, previewURL); err != nil { return err }
s.log.Printf("Updated track %s preview URL to '%s'", id, previewURL)
return nil
}
func (s *MusicService) DeleteTrack(id string) error {
deletedID, err := s.repo.DeleteTrack(id)
if err != nil { return err }
if deletedID == "" { return errors.NewNotExistError("Track does not exist") }
s.log.Printf("Deleted track %s", id)
return nil
}

View file

@ -1,8 +0,0 @@
package validator
import "regexp"
var idRegexp = regexp.MustCompile(`^[a-zA-Z0-9\-_\.]+$`)
func ValidateID(id string) bool {
return idRegexp.MatchString(id)
}

View file

@ -1,39 +0,0 @@
package validator_test
import (
"arimelody-web/service/validator"
"testing"
"gotest.tools/v3/assert"
)
func Test_ValidateID(t *testing.T) {
t.Run("accepts alphanumberic ID", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("abcDEF123"), true)
})
t.Run("accepts hyphens", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("a0-1b"), true)
})
t.Run("accepts underscores", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("a0_1b"), true)
})
t.Run("accepts periods", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("a0.1b"), true)
})
t.Run("rejects other characters", func(t *testing.T) {
t.Run("emoji", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("🗣️🔥‼️"), false)
})
t.Run("slashes", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("this/could/be/really/bad/for/the/router"), false)
})
t.Run("question marks", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("query?these=nuts"), false)
})
t.Run("hashes", func(t *testing.T) {
assert.Equal(t, validator.ValidateID("unnecessary#tagging"), false)
})
})
}

View file

@ -83,10 +83,9 @@
{{else if .IsSingle}}
{{with index .Tracks 0}}
{{if .Description}}
<p id="description">{{.Description}}</p>
{{end}}
{{$Track := index .Tracks 0}}
{{if $Track.Description}}
<p id="description">{{$Track.Description}}</p>
{{end}}
{{end}}
@ -133,18 +132,18 @@
{{else if .Tracks}}
<div id="tracks">
<h2>TRACKS</h2>
{{range .Tracks}}
{{range $i, $track := .Tracks}}
<details>
<summary class="album-track-title">{{.Number}}. {{.Title}}</summary>
<summary class="album-track-title">{{$track.Add $i 1}}. {{$track.Title}}</summary>
{{if .Description}}
{{if $track.Description}}
<p class="album-track-subheading">DESCRIPTION</p>
{{.Description}}
{{$track.Description}}
{{end}}
<p class="album-track-subheading">LYRICS</p>
{{if .Lyrics}}
{{.GetLyricsHTML}}
{{if $track.Lyrics}}
{{$track.GetLyricsHTML}}
{{else}}
<span class="empty">No lyrics.</span>
{{end}}

View file

@ -2,15 +2,14 @@ package view
import (
"arimelody-web/controller"
"arimelody-web/model/app"
"arimelody-web/model/twitch"
"arimelody-web/model"
"arimelody-web/templates"
"fmt"
"net/http"
"os"
)
func IndexHandler(app *app.AppState) http.Handler {
func IndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
@ -19,10 +18,10 @@ func IndexHandler(app *app.AppState) http.Handler {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
type IndexData struct {
TwitchStatus *twitch.StreamInfo
TwitchStatus *model.TwitchStreamInfo
}
var err error
var twitchStatus *twitch.StreamInfo = nil
var twitchStatus *model.TwitchStreamInfo = nil
if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 {
twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster)
if err != nil {

View file

@ -1,19 +1,18 @@
package view
import (
"fmt"
"net/http"
"os"
"fmt"
"net/http"
"os"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/model/app"
"arimelody-web/templates"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/templates"
)
// HTTP HANDLER METHODS
func MusicHandler(app *app.AppState) http.Handler {
func MusicHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -22,7 +21,7 @@ func MusicHandler(app *app.AppState) http.Handler {
return
}
release, err := app.MusicService.GetFullReleaseByID(r.URL.Path[1:])
release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true)
if err != nil {
http.NotFound(w, r)
return
@ -34,9 +33,9 @@ func MusicHandler(app *app.AppState) http.Handler {
return mux
}
func ServeCatalog(app *app.AppState) http.Handler {
func ServeCatalog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := app.MusicService.GetAllFullReleases(true, 0)
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
if err != nil {
fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -56,7 +55,7 @@ func ServeCatalog(app *app.AppState) http.Handler {
})
}
func ServeGateway(app *app.AppState, release *model.Release) http.Handler {
func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases
privileged := false
@ -79,15 +78,15 @@ func ServeGateway(app *app.AppState, release *model.Release) http.Handler {
}
}
if !release.IsReleased() && !privileged {
release.Tracks = nil
release.Credits = nil
release.Links = nil
response := *release
if release.IsReleased() || privileged {
response.Tracks = release.Tracks
response.Credits = release.Credits
response.Links = release.Links
}
for i, track := range release.Tracks { track.Number = i + 1 }
err := templates.MusicGatewayTemplate.Execute(w, release)
err := templates.MusicGatewayTemplate.Execute(w, response)
if err != nil {
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)