logs in use; new audit log panel!

This commit is contained in:
ari melody 2025-02-07 16:40:58 +00:00
parent 1397274967
commit d9b71381b0
Signed by: ari
GPG key ID: CF99829C92678188
16 changed files with 418 additions and 75 deletions

View file

@ -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)

View file

@ -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
View 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
View 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;
}

View file

@ -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"),

View file

@ -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
View 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}}