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/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/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/log/log.go b/log/log.go
index 3344bbe..3d023ab 100644
--- a/log/log.go
+++ b/log/log.go
@@ -25,8 +25,10 @@ type (
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"
)
@@ -40,7 +42,7 @@ const DEFAULT_LOG_PAGE_LENGTH = 25
func (self *Logger) Info(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
- fmt.Printf("[%s] INFO: %s", logType, logString)
+ 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)
@@ -49,25 +51,20 @@ func (self *Logger) Info(logType string, format string, args ...any) {
func (self *Logger) Warn(logType string, format string, args ...any) {
logString := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stderr, "[%s] WARN: %s", logType, logString)
+ 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) Fatal(logType string, format string, args ...any) {
- fmt.Fprintf(os.Stderr, fmt.Sprintf("[%s] FATAL: %s", logType, format), args...)
- // we won't need to push fatal logs to DB, as these usually precede a panic or crash
-}
-
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, offset int, limit int) ([]*Log, error) {
+func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) {
logs := []*Log{}
params := []any{ limit, offset }
@@ -80,7 +77,11 @@ func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, conten
}
if len(levelFilters) > 0 {
- conditions += " AND level IN ("
+ 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 {
@@ -92,7 +93,11 @@ func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, conten
}
if len(typeFilters) > 0 {
- conditions += " AND type IN ("
+ 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 {
@@ -108,8 +113,16 @@ func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, conten
conditions,
)
- // TODO: remove after testing
- fmt.Println(query)
+ /*
+ 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 {
diff --git a/main.go b/main.go
index b18681b..03e9d77 100644
--- a/main.go
+++ b/main.go
@@ -19,8 +19,8 @@ import (
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/templates"
+ "arimelody-web/log"
"arimelody-web/view"
- "arimelody-web/log"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
@@ -78,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]
@@ -119,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
@@ -148,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
@@ -223,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
@@ -234,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())),
@@ -249,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
@@ -301,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":
@@ -340,19 +348,28 @@ 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 "testLogSearch":
- // TODO: rename to "logs"; add parameters
- logger := log.Logger { DB: app.DB }
- logs, err := logger.Search([]log.LogLevel{ log.LEVEL_INFO, log.LEVEL_WARN }, []string{ log.TYPE_ACCOUNT, log.TYPE_MUSIC }, "ari", 0, 100)
+ 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 _, log := range(logs) {
- fmt.Printf("[%s] [%s] [%d] [%s] %s\n", log.CreatedAt.Format(time.UnixDate), log.ID, log.Level, log.Type, log.Content)
+ 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
}
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"`
}
)