logs in use; new audit log panel!
This commit is contained in:
parent
1397274967
commit
d9b71381b0
16 changed files with 418 additions and 75 deletions
|
@ -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}}
|
Loading…
Add table
Add a link
Reference in a new issue