logs in use; new audit log panel!
This commit is contained in:
parent
1397274967
commit
d9b71381b0
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
67
admin/logshttp.go
Normal file
67
admin/logshttp.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func logsHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
levelFilter := []log.LogLevel{}
|
||||
typeFilter := []string{}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
for key, value := range r.URL.Query() {
|
||||
if strings.HasPrefix(key, "level-") && value[0] == "on" {
|
||||
m := map[string]log.LogLevel{
|
||||
"info": log.LEVEL_INFO,
|
||||
"warn": log.LEVEL_WARN,
|
||||
}
|
||||
level, ok := m[strings.TrimPrefix(key, "level-")]
|
||||
if ok {
|
||||
levelFilter = append(levelFilter, level)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, "type-") && value[0] == "on" {
|
||||
typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-")))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type LogsResponse struct {
|
||||
Session *model.Session
|
||||
Logs []*log.Log
|
||||
}
|
||||
|
||||
err = logsTemplate.Execute(w, LogsResponse{
|
||||
Session: session,
|
||||
Logs: logs,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
85
admin/static/logs.css
Normal file
85
admin/static/logs.css
Normal file
|
@ -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;
|
||||
}
|
|
@ -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"),
|
||||
|
|
|
@ -23,7 +23,14 @@
|
|||
<div class="nav-item">
|
||||
<a href="/admin">home</a>
|
||||
</div>
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logs">logs</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex-fill"></div>
|
||||
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
||||
|
|
68
admin/views/logs.html
Normal file
68
admin/views/logs.html
Normal file
|
@ -0,0 +1,68 @@
|
|||
{{define "head"}}
|
||||
<title>Audit Logs - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<link rel="stylesheet" href="/admin/static/logs.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Audit Logs</h1>
|
||||
|
||||
<form action="/admin/logs" method="GET">
|
||||
<div id="search">
|
||||
<input type="text" name="q" value="" placeholder="Filter by message...">
|
||||
<button type="submit" class="save">Search</button>
|
||||
</div>
|
||||
<div id="filters">
|
||||
<div>
|
||||
<p>Level:</p>
|
||||
<label for="level-info">Info</label>
|
||||
<input type="checkbox" name="level-info" id="level-info">
|
||||
<label for="level-warn">Warning</label>
|
||||
<input type="checkbox" name="level-warn" id="level-warn">
|
||||
</div>
|
||||
<div>
|
||||
<p>Type:</p>
|
||||
<label for="type-account">Account</label>
|
||||
<input type="checkbox" name="type-account" id="type-account">
|
||||
<label for="type-music">Music</label>
|
||||
<input type="checkbox" name="type-music" id="type-music">
|
||||
<label for="type-artist">Artist</label>
|
||||
<input type="checkbox" name="type-artist" id="type-artist">
|
||||
<label for="type-blog">Blog</label>
|
||||
<input type="checkbox" name="type-blog" id="type-blog">
|
||||
<label for="type-artwork">Artwork</label>
|
||||
<input type="checkbox" name="type-artwork" id="type-artwork">
|
||||
<label for="type-files">Files</label>
|
||||
<input type="checkbox" name="type-files" id="type-files">
|
||||
<label for="type-misc">Misc</label>
|
||||
<input type="checkbox" name="type-misc" id="type-misc">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<table id="logs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="log-time">Time</th>
|
||||
<th class="log-level">Level</th>
|
||||
<th class="log-type">Type</th>
|
||||
<th class="log-content">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Logs}}
|
||||
<tr class="log {{lower (parseLevel .Level)}}">
|
||||
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
||||
<td class="log-level">{{parseLevel .Level}}</td>
|
||||
<td class="log-type">{{titleCase .Type}}</td>
|
||||
<td class="log-content">{{.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
{{end}}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
20
api/track.go
20
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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
19
controller/ip.go
Normal file
19
controller/ip.go
Normal file
|
@ -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
|
||||
}
|
37
log/log.go
37
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 {
|
||||
|
|
35
main.go
35
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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
@ -24,6 +24,7 @@ type (
|
|||
Tracks []*Track `json:"tracks"`
|
||||
Credits []*Credit `json:"credits"`
|
||||
Links []*Link `json:"links"`
|
||||
CreatedAt time.Time `json:"-" db:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue