diff --git a/admin/accounthttp.go b/admin/accounthttp.go
index 098eb59..fc03d77 100644
--- a/admin/accounthttp.go
+++ b/admin/accounthttp.go
@@ -6,9 +6,9 @@ import (
"net/http"
"net/url"
"os"
- "time"
"arimelody-web/controller"
+ "arimelody-web/log"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
@@ -115,6 +115,8 @@ func changePasswordHandler(app *model.AppState) http.Handler {
return
}
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(r))
+
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
@@ -143,11 +145,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
// check password
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
- fmt.Printf(
- "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n",
- time.Now().Format(time.UnixDate),
- session.Account.Username,
- )
+ app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(r))
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
@@ -161,11 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
return
}
- fmt.Printf(
- "[%s] INFO: Account \"%s\" deleted by user request.\n",
- time.Now().Format(time.UnixDate),
- session.Account.Username,
- )
+ app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(r))
controller.SetSessionAccount(app.DB, session, nil)
controller.SetSessionError(app.DB, session, "")
@@ -324,6 +318,8 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
return
}
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
+
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
@@ -365,6 +361,8 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
return
}
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
+
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
diff --git a/admin/http.go b/admin/http.go
index 0ca61a3..c70dd1d 100644
--- a/admin/http.go
+++ b/admin/http.go
@@ -11,6 +11,7 @@ import (
"time"
"arimelody-web/controller"
+ "arimelody-web/log"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
@@ -39,6 +40,8 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app))))
+ mux.Handle("/logs", requireAccount(logsHandler(app)))
+
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
@@ -198,15 +201,12 @@ func registerAccountHandler(app *model.AppState) http.Handler {
return
}
- fmt.Printf(
- "[%s]: Account registered: %s (%s)\n",
- time.Now().Format(time.UnixDate),
- account.Username,
- account.ID,
- )
+ app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(r))
err = controller.DeleteInvite(app.DB, invite.Code)
- if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
+ if err != nil {
+ app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
+ }
// registration success!
controller.SetSessionAccount(app.DB, session, &account)
@@ -277,11 +277,7 @@ func loginHandler(app *model.AppState) http.Handler {
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
- fmt.Printf(
- "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n",
- time.Now().Format(time.UnixDate),
- account.Username,
- )
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(r))
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
@@ -307,15 +303,11 @@ func loginHandler(app *model.AppState) http.Handler {
return
}
- fmt.Printf(
- "[%s] INFO: Account \"%s\" logged in\n",
- time.Now().Format(time.UnixDate),
- account.Username,
- )
-
- // TODO: log login activity to user
-
// login success!
+ // TODO: log login activity to user
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(r))
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
+
err = controller.SetSessionAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
@@ -371,6 +363,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
totpCode := r.FormValue("totp")
if len(totpCode) != controller.TOTP_CODE_LENGTH {
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
@@ -384,17 +377,13 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
return
}
if totpMethod == nil {
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
- fmt.Printf(
- "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n",
- time.Now().Format(time.UnixDate),
- session.AttemptAccount.Username,
- totpMethod.Name,
- )
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
diff --git a/admin/logshttp.go b/admin/logshttp.go
new file mode 100644
index 0000000..93dc5b7
--- /dev/null
+++ b/admin/logshttp.go
@@ -0,0 +1,67 @@
+package admin
+
+import (
+ "arimelody-web/log"
+ "arimelody-web/model"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+)
+
+func logsHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+
+ levelFilter := []log.LogLevel{}
+ typeFilter := []string{}
+
+ query := r.URL.Query().Get("q")
+
+ for key, value := range r.URL.Query() {
+ if strings.HasPrefix(key, "level-") && value[0] == "on" {
+ m := map[string]log.LogLevel{
+ "info": log.LEVEL_INFO,
+ "warn": log.LEVEL_WARN,
+ }
+ level, ok := m[strings.TrimPrefix(key, "level-")]
+ if ok {
+ levelFilter = append(levelFilter, level)
+ }
+ continue
+ }
+
+ if strings.HasPrefix(key, "type-") && value[0] == "on" {
+ typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-")))
+ continue
+ }
+ }
+
+ logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ type LogsResponse struct {
+ Session *model.Session
+ Logs []*log.Log
+ }
+
+ err = logsTemplate.Execute(w, LogsResponse{
+ Session: session,
+ Logs: logs,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ })
+}
diff --git a/admin/releasehttp.go b/admin/releasehttp.go
index be5052b..7ef4d37 100644
--- a/admin/releasehttp.go
+++ b/admin/releasehttp.go
@@ -22,7 +22,7 @@ func serveRelease(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
- fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
+ fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -86,7 +86,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
- fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
+ fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -113,7 +113,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
- fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
+ fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -166,7 +166,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
- fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
+ fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
diff --git a/admin/static/logs.css b/admin/static/logs.css
new file mode 100644
index 0000000..6ed91b5
--- /dev/null
+++ b/admin/static/logs.css
@@ -0,0 +1,85 @@
+main {
+ width: min(1080px, calc(100% - 2em))!important
+}
+
+form {
+ margin: 1em 0;
+}
+
+div#search {
+ display: flex;
+}
+
+#search input {
+ margin: 0;
+ flex-grow: 1;
+
+ border-right: none;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+#search button {
+ padding: 0 .5em;
+
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+form #filters p {
+ margin: .5em 0 0 0;
+}
+form #filters label {
+ display: inline;
+}
+form #filters input {
+ margin-right: 1em;
+ display: inline;
+}
+
+#logs {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+#logs tr {
+}
+
+#logs tr td {
+ border-bottom: 1px solid #8888;
+}
+
+#logs tr td:nth-child(even) {
+ background: #00000004;
+}
+
+#logs th, #logs td {
+ padding: .4em .8em;
+}
+
+td, th {
+ width: 1%;
+ text-align: left;
+ white-space: nowrap;
+}
+td.log-level,
+th.log-level,
+td.log-type,
+th.log-type {
+ text-align: center;
+}
+td.log-content,
+td.log-content {
+ width: 100%;
+}
+
+.log:hover {
+ background: #fff8;
+}
+
+.log.warn {
+ background: #ffe86a;
+}
+.log.warn:hover {
+ background: #ffec81;
+}
diff --git a/admin/templates.go b/admin/templates.go
index 49c118b..12cdf08 100644
--- a/admin/templates.go
+++ b/admin/templates.go
@@ -1,8 +1,12 @@
package admin
import (
- "html/template"
- "path/filepath"
+ "arimelody-web/log"
+ "fmt"
+ "html/template"
+ "path/filepath"
+ "strings"
+ "time"
)
var indexTemplate = template.Must(template.ParseFiles(
@@ -48,6 +52,37 @@ var totpConfirmTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "totp-confirm.html"),
))
+var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
+ "parseLevel": func(level log.LogLevel) string {
+ switch level {
+ case log.LEVEL_INFO:
+ return "INFO"
+ case log.LEVEL_WARN:
+ return "WARN"
+ }
+ return fmt.Sprintf("%d?", level)
+ },
+ "titleCase": func(logType string) string {
+ runes := []rune(logType)
+ for i, r := range runes {
+ if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
+ runes[i] = r + ('A' - 'a')
+ }
+ }
+ return string(runes)
+ },
+ "lower": func(str string) string { return strings.ToLower(str) },
+ "prettyTime": func(t time.Time) string {
+ // return t.Format("2006-01-02 15:04:05")
+ // return t.Format("15:04:05, 2 Jan 2006")
+ return t.Format("02 Jan 2006, 15:04:05")
+ },
+}).ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("views", "prideflag.html"),
+ filepath.Join("admin", "views", "logs.html"),
+))
+
var releaseTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
diff --git a/admin/views/layout.html b/admin/views/layout.html
index 8c34c8e..52b0620 100644
--- a/admin/views/layout.html
+++ b/admin/views/layout.html
@@ -23,7 +23,14 @@
+ {{if .Session.Account}}
+
+ {{end}}
+
+
{{if .Session.Account}}
account ({{.Session.Account.Username}})
diff --git a/admin/views/logs.html b/admin/views/logs.html
new file mode 100644
index 0000000..e3a3ccb
--- /dev/null
+++ b/admin/views/logs.html
@@ -0,0 +1,68 @@
+{{define "head"}}
+
Audit Logs - ari melody 💫
+
+
+
+{{end}}
+
+{{define "content"}}
+
+ Audit Logs
+
+
+
+
+
+
+
+
+ Time |
+ Level |
+ Type |
+ Message |
+
+
+
+ {{range .Logs}}
+
+ {{prettyTime .CreatedAt}} |
+ {{parseLevel .Level}} |
+ {{titleCase .Type}} |
+ {{.Content}} |
+
+ {{end}}
+
+
+
+{{end}}
diff --git a/api/api.go b/api/api.go
index 4edd07b..d3c83ce 100644
--- a/api/api.go
+++ b/api/api.go
@@ -27,7 +27,7 @@ func Handler(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
- fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
+ fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -69,7 +69,7 @@ func Handler(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
- fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
+ fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -111,7 +111,7 @@ func Handler(app *model.AppState) http.Handler {
http.NotFound(w, r)
return
}
- fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
+ fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
diff --git a/api/artist.go b/api/artist.go
index 51c9d62..9006cc3 100644
--- a/api/artist.go
+++ b/api/artist.go
@@ -11,6 +11,7 @@ import (
"time"
"arimelody-web/controller"
+ "arimelody-web/log"
"arimelody-web/model"
)
@@ -88,6 +89,8 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
func CreateArtist(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
@@ -112,12 +115,16 @@ func CreateArtist(app *model.AppState) http.Handler {
return
}
+ app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username)
+
w.WriteHeader(http.StatusCreated)
})
}
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
fmt.Printf("WARN: Failed to update artist: %s\n", err)
@@ -158,11 +165,15 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
})
}
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
err := controller.DeleteArtist(app.DB, artist.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
@@ -172,5 +183,7 @@ func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
})
}
diff --git a/api/release.go b/api/release.go
index b89cec8..efed8dd 100644
--- a/api/release.go
+++ b/api/release.go
@@ -11,6 +11,7 @@ import (
"time"
"arimelody-web/controller"
+ "arimelody-web/log"
"arimelody-web/model"
)
@@ -189,10 +190,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
func CreateRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
+ session := r.Context().Value("session").(*model.Session)
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
@@ -226,6 +224,8 @@ func CreateRelease(app *model.AppState) http.Handler {
return
}
+ app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username)
+
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
encoder := json.NewEncoder(w)
@@ -240,6 +240,8 @@ func CreateRelease(app *model.AppState) http.Handler {
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
if r.URL.Path == "/" {
http.NotFound(w, r)
return
@@ -304,11 +306,15 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
if err != nil {
@@ -325,11 +331,15 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
type creditJSON struct {
Artist string
Role string
@@ -366,15 +376,14 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPut {
- http.NotFound(w, r)
- return
- }
+ session := r.Context().Value("session").(*model.Session)
var links = []*model.Link{}
err := json.NewDecoder(r.Body).Decode(&links)
@@ -392,11 +401,15 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
})
}
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
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") {
@@ -406,5 +419,7 @@ func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
})
}
diff --git a/api/track.go b/api/track.go
index c342e08..e7d7c07 100644
--- a/api/track.go
+++ b/api/track.go
@@ -6,6 +6,7 @@ import (
"net/http"
"arimelody-web/controller"
+ "arimelody-web/log"
"arimelody-web/model"
)
@@ -75,10 +76,7 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
func CreateTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
+ session := r.Context().Value("session").(*model.Session)
var track model.Track
err := json.NewDecoder(r.Body).Decode(&track)
@@ -99,6 +97,8 @@ func CreateTrack(app *model.AppState) http.Handler {
return
}
+ app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username)
+
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(id))
@@ -107,11 +107,13 @@ func CreateTrack(app *model.AppState) http.Handler {
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPut || r.URL.Path == "/" {
+ if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
+ session := r.Context().Value("session").(*model.Session)
+
err := json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@@ -130,6 +132,8 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
return
}
+ app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
+
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
@@ -142,16 +146,20 @@ func UpdateTrack(app *model.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.Method != http.MethodDelete || r.URL.Path == "/" {
+ if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
+ session := r.Context().Value("session").(*model.Session)
+
var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+
+ app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
})
}
diff --git a/api/uploads.go b/api/uploads.go
index ddcf6ee..60ab7dd 100644
--- a/api/uploads.go
+++ b/api/uploads.go
@@ -1,6 +1,7 @@
package api
import (
+ "arimelody-web/log"
"arimelody-web/model"
"bufio"
"encoding/base64"
@@ -49,5 +50,7 @@ func HandleImageUpload(app *model.AppState, data *string, directory string, file
return "", nil
}
+ app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
+
return filename, nil
}
diff --git a/controller/ip.go b/controller/ip.go
new file mode 100644
index 0000000..4b1126d
--- /dev/null
+++ b/controller/ip.go
@@ -0,0 +1,19 @@
+package controller
+
+import (
+ "net/http"
+ "slices"
+)
+
+// Returns the request's original IP address, resolving the `x-forwarded-for`
+// header if the request originates from a trusted proxy.
+func ResolveIP(r *http.Request) string {
+ trustedProxies := []string{ "10.4.20.69" }
+ if slices.Contains(trustedProxies, r.RemoteAddr) {
+ forwardedFor := r.Header.Get("x-forwarded-for")
+ if len(forwardedFor) > 0 {
+ return forwardedFor
+ }
+ }
+ return r.RemoteAddr
+}
diff --git a/controller/migrator.go b/controller/migrator.go
index d0011ee..b053a27 100644
--- a/controller/migrator.go
+++ b/controller/migrator.go
@@ -8,7 +8,7 @@ import (
"github.com/jmoiron/sqlx"
)
-const DB_VERSION int = 2
+const DB_VERSION int = 3
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@@ -41,6 +41,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "001-pre-versioning")
oldDBVersion = 2
+ case 2:
+ ApplyMigration(db, "002-audit-logs")
+ oldDBVersion = 3
+
}
}
diff --git a/log/log.go b/log/log.go
new file mode 100644
index 0000000..3d023ab
--- /dev/null
+++ b/log/log.go
@@ -0,0 +1,142 @@
+package log
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+)
+
+type (
+ Logger struct {
+ DB *sqlx.DB
+ }
+
+ Log struct {
+ ID string `json:"id" db:"id"`
+ Level LogLevel `json:"level" db:"level"`
+ Type string `json:"type" db:"type"`
+ Content string `json:"content" db:"content"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
+ }
+)
+
+const (
+ TYPE_ACCOUNT string = "account"
+ TYPE_MUSIC string = "music"
+ TYPE_ARTIST string = "artist"
+ TYPE_BLOG string = "blog"
+ TYPE_ARTWORK string = "artwork"
+ TYPE_FILES string = "files"
+ TYPE_MISC string = "misc"
+)
+
+type LogLevel int
+const (
+ LEVEL_INFO LogLevel = 0
+ LEVEL_WARN LogLevel = 1
+)
+
+const DEFAULT_LOG_PAGE_LENGTH = 25
+
+func (self *Logger) Info(logType string, format string, args ...any) {
+ logString := fmt.Sprintf(format, args...)
+ fmt.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString)
+ err := createLog(self.DB, LEVEL_INFO, logType, logString)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
+ }
+}
+
+func (self *Logger) Warn(logType string, format string, args ...any) {
+ logString := fmt.Sprintf(format, args...)
+ fmt.Fprintf(os.Stderr, "[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString)
+ err := createLog(self.DB, LEVEL_WARN, logType, logString)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
+ }
+}
+
+func (self *Logger) Fetch(id string) (*Log, error) {
+ log := Log{}
+ err := self.DB.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
+ return &log, err
+}
+
+func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) {
+ logs := []*Log{}
+
+ params := []any{ limit, offset }
+ conditions := ""
+
+ if len(content) > 0 {
+ content = "%" + content + "%"
+ conditions += " WHERE content LIKE $3"
+ params = append(params, content)
+ }
+
+ if len(levelFilters) > 0 {
+ if len(conditions) > 0 {
+ conditions += " AND level IN ("
+ } else {
+ conditions += " WHERE level IN ("
+ }
+ for i := range levelFilters {
+ conditions += fmt.Sprintf("$%d", len(params) + 1)
+ if i < len(levelFilters) - 1 {
+ conditions += ","
+ }
+ params = append(params, levelFilters[i])
+ }
+ conditions += ")"
+ }
+
+ if len(typeFilters) > 0 {
+ if len(conditions) > 0 {
+ conditions += " AND type IN ("
+ } else {
+ conditions += " WHERE type IN ("
+ }
+ for i := range typeFilters {
+ conditions += fmt.Sprintf("$%d", len(params) + 1)
+ if i < len(typeFilters) - 1 {
+ conditions += ","
+ }
+ params = append(params, typeFilters[i])
+ }
+ conditions += ")"
+ }
+
+ query := fmt.Sprintf(
+ "SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2",
+ conditions,
+ )
+
+ /*
+ fmt.Printf("%s (", query)
+ for i, param := range params {
+ fmt.Print(param)
+ if i < len(params) - 1 {
+ fmt.Print(", ")
+ }
+ }
+ fmt.Print(")\n")
+ */
+
+ err := self.DB.Select(&logs, query, params...)
+ if err != nil {
+ return nil, err
+ }
+ return logs, nil
+}
+
+func createLog(db *sqlx.DB, logLevel LogLevel, logType string, content string) error {
+ _, err := db.Exec(
+ "INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
+ logLevel,
+ logType,
+ content,
+ )
+ return err
+}
diff --git a/main.go b/main.go
index 7e8e06f..03e9d77 100644
--- a/main.go
+++ b/main.go
@@ -3,7 +3,7 @@ package main
import (
"errors"
"fmt"
- "log"
+ stdLog "log"
"math"
"math/rand"
"net/http"
@@ -19,6 +19,7 @@ import (
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/templates"
+ "arimelody-web/log"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
@@ -77,6 +78,8 @@ func main() {
app.DB.SetMaxIdleConns(10)
defer app.DB.Close()
+ app.Log = log.Logger{ DB: app.DB }
+
// handle command arguments
if len(os.Args) > 1 {
arg := os.Args[1]
@@ -118,6 +121,7 @@ func main() {
os.Exit(1)
}
+ app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
fmt.Printf("%s\n", url)
return
@@ -147,6 +151,7 @@ func main() {
os.Exit(1)
}
+ app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return
@@ -222,6 +227,7 @@ func main() {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
os.Exit(1)
}
+ app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
return
@@ -233,6 +239,7 @@ func main() {
os.Exit(1)
}
+ app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
fmt.Printf(
"Here you go! This code expires in %d hours: %s\n",
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
@@ -248,6 +255,7 @@ func main() {
os.Exit(1)
}
+ app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
fmt.Printf("Invites deleted successfully.\n")
return
@@ -300,11 +308,12 @@ func main() {
account.Password = string(hashedPassword)
err = controller.UpdateAccount(app.DB, account)
if err != nil {
- fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
+ fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
os.Exit(1)
}
- fmt.Printf("Account \"%s\" deleted 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":
@@ -339,9 +348,30 @@ func main() {
os.Exit(1)
}
+ app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
-
+
+ case "logs":
+ // TODO: add log search parameters
+ logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
+ os.Exit(1)
+ }
+ for _, item := range(logs) {
+ levelStr := ""
+ switch item.Level {
+ case log.LEVEL_INFO:
+ levelStr = "INFO"
+ case log.LEVEL_WARN:
+ levelStr = "WARN"
+ default:
+ levelStr = fmt.Sprintf("? (%d)", item.Level)
+ }
+ fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
+ }
+ return
}
// command help
@@ -401,7 +431,7 @@ func main() {
// start the web server!
mux := createServeMux(&app)
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
- log.Fatal(
+ stdLog.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
HTTPLog(DefaultHeaders(mux)),
))
diff --git a/model/appstate.go b/model/appstate.go
index 6a965d5..2516b6e 100644
--- a/model/appstate.go
+++ b/model/appstate.go
@@ -1,6 +1,10 @@
package model
-import "github.com/jmoiron/sqlx"
+import (
+ "github.com/jmoiron/sqlx"
+
+ "arimelody-web/log"
+)
type (
DBConfig struct {
@@ -29,5 +33,6 @@ type (
AppState struct {
DB *sqlx.DB
Config Config
+ Log log.Logger
}
)
diff --git a/model/release.go b/model/release.go
index 7b0c8d5..42d6fba 100644
--- a/model/release.go
+++ b/model/release.go
@@ -24,6 +24,7 @@ type (
Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"`
Links []*Link `json:"links"`
+ CreatedAt time.Time `json:"-" db:"created_at"`
}
)
diff --git a/schema-migration/000-init.sql b/schema-migration/000-init.sql
index 42b982a..f70dee6 100644
--- a/schema-migration/000-init.sql
+++ b/schema-migration/000-init.sql
@@ -2,6 +2,15 @@
-- Tables
--
+-- Audit logs
+CREATE TABLE arimelody.auditlog (
+ id UUID DEFAULT gen_random_uuid(),
+ level int NOT NULL DEFAULT 0,
+ type TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
+);
+
-- Accounts
CREATE TABLE arimelody.account (
id UUID DEFAULT gen_random_uuid(),
@@ -9,7 +18,7 @@ CREATE TABLE arimelody.account (
password TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
- created_at TIMESTAMP DEFAULT current_timestamp
+ created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
@@ -74,7 +83,8 @@ CREATE TABLE arimelody.musicrelease (
buyname text,
buylink text,
copyright text,
- copyrightURL text
+ copyrightURL text,
+ created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
);
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
diff --git a/schema-migration/002-audit-logs.sql b/schema-migration/002-audit-logs.sql
new file mode 100644
index 0000000..7da43b5
--- /dev/null
+++ b/schema-migration/002-audit-logs.sql
@@ -0,0 +1,12 @@
+-- Audit logs
+CREATE TABLE arimelody.auditlog (
+ id UUID DEFAULT gen_random_uuid(),
+ level int NOT NULL DEFAULT 0,
+ type TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
+);
+
+-- Need moar timestamps
+ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp;
+ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL;
diff --git a/view/music.go b/view/music.go
index 89e428c..2d40ef0 100644
--- a/view/music.go
+++ b/view/music.go
@@ -37,7 +37,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
if err != nil {
- fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
+ fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}