Compare commits

...
Sign in to create a new pull request.

39 commits

Author SHA1 Message Date
5341ff3033
some nice css tweaks :3 2025-11-25 22:47:57 +00:00
1d98a6fdca
blog style improvements; fix connection pool drain 2025-11-08 18:43:34 +00:00
0a75216aaf
improve blog editor dark theme 2025-11-08 15:26:56 +00:00
84e40b837a
fix some bad error printing
+ fixed "blog" not being highlighted in admin sidebar when visited
2025-11-08 15:16:43 +00:00
b7c1d85830
use servemux *properly* this time; better error handling for DB gets 2025-11-08 15:04:07 +00:00
a33e6717e0
fix silly admin routing nonsense 2025-11-08 14:13:42 +00:00
09c09b6310
add set current date button to blog editor 2025-11-08 14:00:29 +00:00
0c2aaa0b38
huge blog refactor
tidying up data structures; improvements to blog admin UI/UX, etc.
2025-11-08 12:54:31 +00:00
eaa2f6587d
v1 blog API routes 2025-11-07 19:31:34 +00:00
fec3325503
add latest blog post to admin dashboard view 2025-11-07 18:16:14 +00:00
21912d4ec2
refactor mux path routes
legend has it that if you refactor your code enough times, one day you
will finally be happy
2025-11-07 17:48:06 +00:00
82fd17c836
early admin edit blog page 2025-11-07 02:47:40 +00:00
65366032fd
add blog index to admin dashboard 2025-11-07 01:04:10 +00:00
ee8bf6543e
Merge branch 'dev' into feature/blog
that was FAR LESS PAINFUL!
2025-11-06 22:28:11 +00:00
c547fca0d7
more admin dashboard polish, some code cleanup 2025-11-06 22:26:22 +00:00
3e5ecb9372
Merge branch 'dev' into feature/blog
THAT WAS PAINFUL!
2025-11-06 21:24:52 +00:00
bf9289400e
Merge branch 'dev' into feature/blog 2025-09-07 16:23:37 +01:00
55b0513f67
Merge branch 'dev' into feature/blog 2025-08-22 01:07:55 +01:00
823fe2e356
Merge branch 'dev' into feature/blog 2025-08-14 02:38:39 +01:00
94774352b3
admin home: display latest release, not first (oops) 2025-07-19 00:01:01 +01:00
31a02a12a5
Merge branch 'dev' into feature/blog 2025-07-18 23:47:41 +01:00
bd2dc806d5
refactor: move music admin to /admin/music; keep /admin generic 2025-07-15 16:41:34 +01:00
ddbf3444eb
blog: reading mode fixes, add highight.js for codeblocks 2025-07-05 15:11:51 +01:00
faf6095d16
blog visitor frontend (pretty much) done! 2025-06-24 01:32:30 +01:00
3d64333b4f
blog sidebar, some cosmetic changes 2025-06-23 20:38:28 +01:00
3da0249555
Merge branch 'dev' into feature/blog 2025-06-22 18:05:50 +01:00
053faeb493
Merge branch 'dev' into feature/blog 2025-06-22 18:01:31 +01:00
0596edc4b2
blog: nice comment tweaks 2025-05-21 18:39:33 +01:00
fece0f5da6
blog: display bluesky like and repost counts 2025-05-21 18:38:50 +01:00
486c9ae641
Merge branch 'dev' into feature/blog 2025-05-05 17:57:43 +01:00
82a4cde8c9
Merge branch 'dev' into feature/blog 2025-04-30 18:22:21 +01:00
bc90015a33
Merge branch 'main' into feature/blog 2025-04-29 23:42:56 +01:00
dd8e503b61
Merge branch 'dev' into feature/blog 2025-04-29 23:26:41 +01:00
99e5eb290f
Merge branch 'dev' into feature/blog 2025-04-29 22:06:37 +01:00
0796ea8fde
blog: css tweaks 2025-04-29 22:04:19 +01:00
5aa241e4d6
improve comment callout 2025-04-03 00:00:16 +01:00
835dd344ca
style improvements and bluesky comments! 2025-04-02 23:49:20 +01:00
1a8dc4d9ce
basic blog page! 2025-04-02 23:04:09 +01:00
8eb432539c
consolidate view and views directories 2025-04-02 21:45:28 +01:00
121 changed files with 5347 additions and 1225 deletions

View file

@ -1,4 +1,4 @@
package admin
package account
import (
"database/sql"
@ -7,6 +7,7 @@ import (
"net/url"
"os"
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
@ -15,17 +16,21 @@ import (
"golang.org/x/crypto/bcrypt"
)
func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux := http.NewServeMux()
mux.Handle("/account/totp-setup", totpSetupHandler(app))
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
mux.Handle("/account/", accountIndexHandler(app))
mux.Handle("/account/password", changePasswordHandler(app))
mux.Handle("/account/delete", deleteAccountHandler(app))
mux.Handle("/account/totp-setup", totpSetupHandler(app))
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
return mux
mux.Handle("/account/password", changePasswordHandler(app))
mux.Handle("/account/delete", deleteAccountHandler(app))
mux.ServeHTTP(w, r)
})
}
func accountIndexHandler(app *model.AppState) http.Handler {
@ -45,7 +50,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
}
accountResponse struct {
adminPageData
core.AdminPageData
TOTPs []TOTP
}
)
@ -66,7 +71,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Error = sessionError
err = templates.AccountTemplate.Execute(w, accountResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
TOTPs: totps,
})
if err != nil {
@ -93,7 +98,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -103,7 +108,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -112,7 +117,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -120,7 +125,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
@ -148,7 +153,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -156,7 +161,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -170,7 +175,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
}
type totpConfirmData struct {
adminPageData
core.AdminPageData
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
@ -181,7 +186,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if r.Method == http.MethodGet {
session := r.Context().Value("session").(*model.Session)
err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
err := templates.TOTPSetupTemplate.Execute(w, core.AdminPageData{ Path: "/account", Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -219,7 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
@ -235,7 +240,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
}
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
@ -271,7 +276,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@ -291,7 +296,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
@ -307,7 +312,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -315,7 +320,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
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)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
@ -343,7 +348,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@ -355,7 +360,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -363,6 +368,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
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)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}

379
admin/auth/authhttp.go Normal file
View file

@ -0,0 +1,379 @@
package auth
import (
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"database/sql"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func RegisterAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
type registerData struct {
Session *model.Session
}
render := func() {
err := templates.RegisterTemplate.Execute(w, registerData{ Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure invite code exists in DB
invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
controller.SetSessionError(app.DB, session, "Invalid invite code.")
render()
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: sql.NullString{ String: credentials.Email, Valid: true },
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
render()
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code)
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)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LoginHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
render := func() {
err := templates.LoginTemplate.Execute(w, core.AdminPageData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render()
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("username") || !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, 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)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LoginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
render := func() {
err := templates.LoginTOTPTemplate.Execute(w, core.AdminPageData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
r.ParseForm()
if !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
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(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LogoutHandler(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)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Expires: time.Now(),
Path: "/",
})
err = templates.LogoutTemplate.Execute(w, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
if err != nil {
fmt.Fprintf(
os.Stderr,
"WARN: Failed to increment login failures for \"%s\": %v\n",
account.Username,
err,
)
app.Log.Warn(
log.TYPE_ACCOUNT,
"Failed to increment login failures for \"%s\"",
account.Username,
)
}
if locked {
app.Log.Warn(
log.TYPE_ACCOUNT,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
}
return locked
}

133
admin/blog/blog.go Normal file
View file

@ -0,0 +1,133 @@
package blog
import (
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"fmt"
"net/http"
"os"
"slices"
)
func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux := http.NewServeMux()
mux.Handle("/blogs/{id}", serveBlogPost(app))
mux.Handle("/blogs/", serveBlogIndex(app))
mux.ServeHTTP(w, r)
})
}
type (
blogPostCollection struct {
Year int
Posts []*model.BlogPost
}
)
func (c *blogPostCollection) Clone() blogPostCollection {
return blogPostCollection{
Year: c.Year,
Posts: slices.Clone(c.Posts),
}
}
func serveBlogIndex(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
posts, err := controller.GetBlogPosts(app.DB, false, -1, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
collections := []*blogPostCollection{}
collection := blogPostCollection{
Posts: []*model.BlogPost{},
Year: -1,
}
for i, post := range posts {
if i == 0 {
collection.Year = post.PublishDate.Year()
}
if post.PublishDate.Year() != collection.Year {
clone := collection.Clone()
collections = append(collections, &clone)
collection = blogPostCollection{
Year: post.PublishDate.Year(),
Posts: []*model.BlogPost{},
}
}
collection.Posts = append(collection.Posts, post)
if i == len(posts) - 1 {
collections = append(collections, &collection)
}
}
type blogsData struct {
core.AdminPageData
TotalPosts int
Collections []*blogPostCollection
}
err = templates.BlogsTemplate.Execute(w, blogsData{
AdminPageData: core.AdminPageData{
Path: r.URL.Path,
Session: session,
},
TotalPosts: len(posts),
Collections: collections,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin blog index: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func serveBlogPost(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
blogID := r.PathValue("id")
post, err := controller.GetBlogPost(app.DB, blogID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if post == nil {
http.NotFound(w, r)
return
}
type blogPostData struct {
core.AdminPageData
Post *model.BlogPost
}
err = templates.EditBlogTemplate.Execute(w, blogPostData{
AdminPageData: core.AdminPageData{
Path: r.URL.Path,
Session: session,
},
Post: post,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin edit page for blog %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}

56
admin/core/funcs.go Normal file
View file

@ -0,0 +1,56 @@
package core
import (
"arimelody-web/controller"
"arimelody-web/model"
"context"
"fmt"
"net/http"
"os"
"strings"
)
func RequireAccount(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account == nil {
// TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
func EnforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session == nil {
// create a new session
session, err = controller.CreateSession(app.DB, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Value: session.Token,
Expires: session.ExpiresAt,
Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
HttpOnly: true,
Path: "/",
})
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

8
admin/core/structs.go Normal file
View file

@ -0,0 +1,8 @@
package core
import "arimelody-web/model"
type AdminPageData struct {
Path string
Session *model.Session
}

View file

@ -1,59 +1,35 @@
package admin
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"strings"
"time"
"arimelody-web/admin/account"
"arimelody-web/admin/auth"
"arimelody-web/admin/blog"
"arimelody-web/admin/core"
"arimelody-web/admin/logs"
"arimelody-web/admin/music"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/view"
"golang.org/x/crypto/bcrypt"
)
type adminPageData struct {
Path string
Session *model.Session
}
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
mux.Handle("/register", auth.RegisterAccountHandler(app))
mux.Handle("/login", auth.LoginHandler(app))
mux.Handle("/totp", auth.LoginTOTPHandler(app))
mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app)))
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
}))
mux.Handle("/logs", core.RequireAccount(logs.Handler(app)))
mux.Handle("/music/", core.RequireAccount(music.Handler(app)))
mux.Handle("/blogs/", core.RequireAccount(blog.Handler(app)))
mux.Handle("/login", loginHandler(app))
mux.Handle("/totp", loginTOTPHandler(app))
mux.Handle("/logout", requireAccount(logoutHandler(app)))
mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(accountHandler(app)))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/releases", requireAccount(serveReleases(app)))
mux.Handle("/releases/", requireAccount(serveReleases(app)))
mux.Handle("/artists", requireAccount(serveArtists(app)))
mux.Handle("/artists/", requireAccount(serveArtists(app)))
mux.Handle("/tracks", requireAccount(serveTracks(app)))
mux.Handle("/tracks/", requireAccount(serveTracks(app)))
mux.Handle("/account/", core.RequireAccount(account.Handler(app)))
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/static/admin.css" {
@ -64,18 +40,18 @@ func Handler(app *model.AppState) http.Handler {
http.ServeFile(w, r, "./admin/static/admin.js")
return
}
requireAccount(
core.RequireAccount(
http.StripPrefix("/static",
view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
}))
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
mux.Handle("/", core.RequireAccount(adminIndexHandler(app)))
// response wrapper to make sure a session cookie exists
return enforceSession(app, mux)
return core.EnforceSession(app, mux)
}
func AdminIndexHandler(app *model.AppState) http.Handler {
func adminIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@ -86,413 +62,93 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
releases, err := controller.GetAllReleases(app.DB, false, 3, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releaseCount, err := controller.GetReleaseCount(app.DB, false)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artistCount, err := controller.GetArtistCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
trackCount, err := controller.GetTrackCount(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type BlogPost struct {
*model.BlogPost
Author *model.Account
}
blogPosts, err := controller.GetBlogPosts(app.DB, false, 1, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog posts: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
var latestBlogPost *model.BlogPost = nil
if len(blogPosts) > 0 { latestBlogPost = blogPosts[0] }
blogCount, err := controller.GetBlogPostCount(app.DB, false)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
adminPageData
core.AdminPageData
Releases []*model.Release
ReleaseCount int
Artists []*model.Artist
ArtistCount int
Tracks []*model.Track
TrackCount int
LatestBlogPost *model.BlogPost
BlogCount int
}
err = templates.IndexTemplate.Execute(w, IndexData{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Releases: releases,
ReleaseCount: releaseCount,
Artists: artists,
ArtistCount: artistCount,
Tracks: tracks,
TrackCount: trackCount,
LatestBlogPost: latestBlogPost,
BlogCount: blogCount,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render := func() {
err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure invite code exists in DB
invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
controller.SetSessionError(app.DB, session, "Invalid invite code.")
render()
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: sql.NullString{ String: credentials.Email, Valid: true },
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
render()
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code)
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)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
render := func() {
err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render()
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("username") || !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, 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)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
render := func() {
err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
r.ParseForm()
if !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
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(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func logoutHandler(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)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Expires: time.Now(),
Path: "/",
})
err = templates.LogoutTemplate.Execute(w, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func requireAccount(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account == nil {
// TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
/*
//go:embed "static"
var staticFS embed.FS
@ -513,63 +169,3 @@ func staticHandler() http.Handler {
})
}
*/
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session == nil {
// create a new session
session, err = controller.CreateSession(app.DB, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Value: session.Token,
Expires: session.ExpiresAt,
Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
HttpOnly: true,
Path: "/",
})
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
if err != nil {
fmt.Fprintf(
os.Stderr,
"WARN: Failed to increment login failures for \"%s\": %v\n",
account.Username,
err,
)
app.Log.Warn(
log.TYPE_ACCOUNT,
"Failed to increment login failures for \"%s\"",
account.Username,
)
}
if locked {
app.Log.Warn(
log.TYPE_ACCOUNT,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
}
return locked
}

View file

@ -1,6 +1,7 @@
package admin
package logs
import (
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/log"
"arimelody-web/model"
@ -10,7 +11,7 @@ import (
"strings"
)
func logsHandler(app *model.AppState) http.Handler {
func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
@ -51,12 +52,12 @@ func logsHandler(app *model.AppState) http.Handler {
}
type LogsResponse struct {
adminPageData
core.AdminPageData
Logs []*log.Log
}
err = templates.LogsTemplate.Execute(w, LogsResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Logs: logs,
})
if err != nil {

View file

@ -1,10 +1,11 @@
package admin
package music
import (
"fmt"
"net/http"
"strings"
"os"
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
@ -14,32 +15,24 @@ func serveArtists(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/")
artistID := slices[0]
if len(artistID) > 0 {
serveArtist(app, artistID).ServeHTTP(w, r)
return
}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistsResponse struct {
adminPageData
core.AdminPageData
Artists []*model.Artist
}
err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Artists: artists,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artists page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -55,31 +48,31 @@ func serveArtist(app *model.AppState, artistID string) http.Handler {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil {
fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist credits for %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistResponse struct {
adminPageData
core.AdminPageData
Artist *model.Artist
Credits []*model.Credit
}
err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Artist: artist,
Credits: credits,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

32
admin/music/musichttp.go Normal file
View file

@ -0,0 +1,32 @@
package music
import (
"arimelody-web/model"
"net/http"
)
func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux := http.NewServeMux()
mux.HandleFunc("/music/releases/{id}/", func(w http.ResponseWriter, r *http.Request) {
serveEditRelease(app, r.PathValue("id")).ServeHTTP(w, r)
})
mux.HandleFunc("/music/releases/{id}", func(w http.ResponseWriter, r *http.Request) {
serveRelease(app, r.PathValue("id")).ServeHTTP(w, r)
})
mux.Handle("/music/releases/", serveReleases(app))
mux.HandleFunc("/music/artists/{id}", func(w http.ResponseWriter, r *http.Request) {
serveArtist(app, r.PathValue("id")).ServeHTTP(w, r)
})
mux.Handle("/music/artists/", serveArtists(app))
mux.HandleFunc("/music/tracks/{id}", func(w http.ResponseWriter, r *http.Request) {
serveTrack(app, r.PathValue("id")).ServeHTTP(w, r)
})
mux.Handle("/music/tracks/", serveTracks(app))
mux.ServeHTTP(w, r)
})
}

View file

@ -1,11 +1,11 @@
package admin
package music
import (
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
@ -15,115 +15,101 @@ func serveReleases(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
releaseID := slices[0]
var action string = ""
if len(slices) > 1 {
action = slices[1]
}
if len(releaseID) > 0 {
serveRelease(app, releaseID, action).ServeHTTP(w, r)
return
}
type ReleasesData struct {
adminPageData
core.AdminPageData
Releases []*model.Release
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
adminPageData: adminPageData{
AdminPageData: core.AdminPageData{
Path: r.URL.Path,
Session: session,
},
Releases: releases,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
func serveRelease(app *model.AppState, releaseID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if len(action) > 0 {
switch action {
case "editcredits":
serveEditCredits(release).ServeHTTP(w, r)
return
case "addcredit":
serveAddCredit(app, release).ServeHTTP(w, r)
return
case "newcredit":
serveNewCredit(app).ServeHTTP(w, r)
return
case "editlinks":
serveEditLinks(release).ServeHTTP(w, r)
return
case "edittracks":
serveEditTracks(release).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(app, release).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack(app).ServeHTTP(w, r)
return
}
if release == nil {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
type ReleaseResponse struct {
adminPageData
core.AdminPageData
Release *model.Release
}
for i, track := range release.Tracks {
track.Number = i + 1
}
for i, track := range release.Tracks { track.Number = i + 1 }
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Release: release,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin release page for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveEditRelease(app *model.AppState, releaseID string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
mux := http.NewServeMux()
mux.Handle("GET /music/releases/{id}/editcredits", serveEditCredits(release))
mux.Handle("GET /music/releases/{id}/addcredit", serveAddCredit(app, release))
mux.Handle("GET /music/releases/{id}/newcredit/{artistID}", serveNewCredit(app))
mux.Handle("GET /music/releases/{id}/editlinks", serveEditLinks(release))
mux.Handle("GET /music/releases/{id}/edittracks", serveEditTracks(release))
mux.Handle("GET /music/releases/{id}/addtrack", serveAddTrack(app, release))
mux.Handle("GET /music/releases/{id}/newtrack/{trackID}", serveNewTrack(app))
mux.ServeHTTP(w, r)
})
}
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit credits component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -133,7 +119,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists not on %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -149,7 +135,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
Artists: artists,
})
if err != nil {
fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve add credits component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -157,10 +143,10 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
func serveNewCredit(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artistID := strings.Split(r.URL.Path, "/")[3]
artistID := r.PathValue("artistID")
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %v\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -172,7 +158,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve new credit component for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -183,7 +169,7 @@ func serveEditLinks(release *model.Release) http.Handler {
w.Header().Set("Content-Type", "text/html")
err := templates.EditLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit links component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -195,9 +181,11 @@ func serveEditTracks(release *model.Release) http.Handler {
type editTracksData struct { Release *model.Release }
for i, track := range release.Tracks { track.Number = i + 1 }
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
if err != nil {
fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit tracks component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -207,7 +195,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks not on %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -223,7 +211,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
Tracks: tracks,
})
if err != nil {
fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to add tracks component for %s: %v\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -231,10 +219,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
func serveNewTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
trackID := strings.Split(r.URL.Path, "/")[3]
trackID := r.PathValue("trackID")
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -246,7 +234,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve new track component for %s: %v\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

View file

@ -1,10 +1,11 @@
package admin
package music
import (
"fmt"
"net/http"
"strings"
"os"
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
@ -14,32 +15,24 @@ func serveTracks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/")
trackID := slices[0]
if len(trackID) > 0 {
serveTrack(app, trackID).ServeHTTP(w, r)
return
}
tracks, err := controller.GetAllTracks(app.DB)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TracksResponse struct {
adminPageData
core.AdminPageData
Tracks []*model.Track
}
err = templates.TracksTemplate.Execute(w, TracksResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Tracks: tracks,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin tracks page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -51,7 +44,7 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -62,24 +55,24 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil {
fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases for track %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TrackResponse struct {
adminPageData
core.AdminPageData
Track *model.Track
Releases []*model.Release
}
err = templates.EditTrackTemplate.Execute(w, TrackResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Track: track,
Releases: releases,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %v\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

View file

@ -111,7 +111,7 @@ body {
font-family: "Inter", sans-serif;
font-size: 16px;
color: var(--fg-0);
background: var(--bg-0);
background-color: var(--bg-0);
transition: background .1s ease-out, color .1s ease-out;
}
@ -252,12 +252,6 @@ a {
transition: color .1s ease-out, background-color .1s ease-out;
}
/*
a:hover {
text-decoration: underline;
}
*/
img.icon {
height: .8em;
transition: filter .1s ease-out;
@ -283,7 +277,7 @@ code {
.card {
flex-basis: 40em;
padding: 1em;
background: var(--bg-1);
background-color: var(--bg-1);
border-radius: 16px;
box-shadow: var(--shadow-lg);
@ -361,7 +355,7 @@ a.delete:not(.button) {
font-size: inherit;
color: inherit;
background: var(--bg-2);
background-color: var(--bg-2);
border: none;
border-radius: 10em;
box-shadow: var(--shadow-sm);
@ -380,27 +374,27 @@ button:active, .button:active {
.button.new, button.new {
color: var(--col-on-new);
background: var(--col-new);
background-color: var(--col-new);
}
.button.save, button.save {
color: var(--col-on-save);
background: var(--col-save);
background-color: var(--col-save);
}
.button.delete, button.delete {
color: var(--col-on-delete);
background: var(--col-delete);
background-color: var(--col-delete);
}
.button:hover, button:hover {
color: var(--bg-3);
background: var(--fg-3);
background-color: var(--fg-3);
}
.button:active, button:active {
color: var(--bg-2);
background: var(--fg-0);
background-color: var(--fg-0);
}
.button[disabled], button[disabled] {
color: var(--fg-0) !important;
background: var(--bg-3) !important;
background-color: var(--bg-3) !important;
opacity: .5;
cursor: default !important;
}
@ -436,6 +430,39 @@ input[disabled] {
cursor: not-allowed;
}
.actions {
margin-top: .5em;
display: flex;
flex-direction: row;
gap: .5em;
user-select: none;
color: var(--fg-3);
}
.actions a,
.actions button {
padding: .3em .5em;
display: inline-block;
border-radius: 4px;
background-color: var(--bg-3);
box-shadow: var(--shadow-sm);
transition-property: color, background, transform;
transition-duration: .1s;
transition-timing-function: ease-out;
}
.actions a:hover,
.actions button:hover {
background-color: var(--bg-0);
color: var(--fg-3);
text-decoration: none;
transform: scale(1.05);
}
@media screen and (max-width: 720px) {
main {
padding-top: 0;

View file

@ -2,18 +2,22 @@
padding: .5em;
color: var(--fg-3);
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: background .1s ease-out, color .1s ease-out;
transition-property: background, color, transform;
transition-duration: .1s;
transition-timing-function: ease-out;
}
.artist:hover {
background: var(--bg-1);
background-color: var(--bg-1);
text-decoration: hover;
transform: scale(1.1);
}
.artist .artist-avatar {

View file

@ -4,4 +4,29 @@ document.addEventListener("readystatechange", () => {
document.querySelectorAll(".artists-group .artist").forEach(el => {
hijackClickEvent(el, el.querySelector("a.artist-name"))
});
const newArtistBtn = document.getElementById("create-artist");
if (newArtistBtn) newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/music/artists/" + id;
} else {
alert(text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
});

62
admin/static/blog.css Normal file
View file

@ -0,0 +1,62 @@
.blog-collection {
margin-bottom: 1em;
display: flex;
flex-direction: column;
gap: .5em;
}
.blog-collection h2 {
margin: 0 0 0 1em;
font-size: 1em;
text-transform: uppercase;
font-weight: 600;
color: var(--fg-0);
}
.blogpost {
padding: 1em;
display: block;
border-radius: 8px;
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
.blogpost .title {
margin: 0;
font-size: 1.5em;
}
.blogpost .title small {
display: inline-block;
font-size: .6em;
transform: translateY(-0.1em);
color: var(--fg-0);
}
.blogpost .description {
margin: .5em 0 .6em 0;
color: var(--fg-1);
}
.blogpost .meta {
margin: 0;
font-size: .8em;
color: var(--fg-0);
}
.blogpost .meta .author {
color: var(--fg-1);
}
.blogpost .meta .author img {
width: 1.3em;
height: 1.3em;
margin-right: .2em;
display: inline-block;
transform: translate(0, 4px);
border-radius: 4px;
}
.blogpost a:hover {
text-decoration: underline;
}

25
admin/static/blog.js Normal file
View file

@ -0,0 +1,25 @@
document.addEventListener('readystatechange', () => {
const newBlogBtn = document.getElementById("create-post");
if (newBlogBtn) newBlogBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this blog post:");
if (id == null || id == "") return;
fetch("/api/v1/blog", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/blogs/" + id;
else {
res.text().then(err => {
alert(err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});
});

View file

@ -33,7 +33,7 @@ form#delete-account input {
justify-content: space-between;
color: var(--fg-3);
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
}

View file

@ -6,7 +6,7 @@
gap: 1.2em;
border-radius: 16px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -50,26 +50,11 @@ input[type="text"] {
font-family: inherit;
font-weight: inherit;
color: inherit;
background: var(--bg-0);
background-color: var(--bg-0);
border: none;
border-radius: 4px;
outline: none;
}
input[type="text"]:hover {
border-color: #80808080;
}
input[type="text"]:active,
input[type="text"]:focus {
border-color: #808080;
}
.artist-actions {
margin-top: auto;
display: flex;
gap: .5em;
flex-direction: row;
justify-content: right;
}
.card-header a.button {
text-decoration: none;
@ -84,7 +69,7 @@ input[type="text"]:focus {
align-items: center;
border-radius: 16px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
cursor: pointer;
@ -92,7 +77,7 @@ input[type="text"]:focus {
}
.credit:hover {
background: var(--bg-1);
background-color: var(--bg-1);
}
.release-artwork {

140
admin/static/edit-blog.css Normal file
View file

@ -0,0 +1,140 @@
input[type="text"] {
padding: .3em .5em;
font-size: inherit;
font-family: inherit;
border: none;
border-radius: 4px;
outline: none;
color: var(--fg-3);
background-color: var(--bg-2);
box-shadow: var(--shadow-sm);
transition: background-color .1s ease-out, color .1s ease-out;
}
#blogpost {
margin-bottom: 1em;
padding: 1.5em;
border-radius: 8px;
background-color: var(--bg-1);
box-shadow: var(--shadow-lg);
transition: background-color .1s ease-out, color .1s ease-out;
}
#blogpost label {
margin: 1.2em 0 .2em .1em;
display: block;
font-size: .8em;
text-transform: uppercase;
font-weight: 600;
}
#blogpost label:first-of-type {
margin-top: 0;
}
#blogpost button#set-current-date {
margin: 0 .5em;
padding: .4em .8em;
}
#blogpost h2 {
margin: 0;
font-size: 2em;
}
#blogpost #title {
width: 100%;
margin: 0 -.2em;
padding: 0 .2em;
resize: none;
font-family: inherit;
font-size: inherit;
font-weight: bold;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: var(--fg-3);
outline: none;
cursor: pointer;
transition: background-color .1s ease-out, color .1s ease-out, border-color .1s ease-out;
/*position: relative; outline: none;*/
white-space: pre-wrap; overflow-wrap: break-word;
}
#blogpost #title:hover {
background-color: var(--bg-3);
border-color: var(--fg-0);
}
#blogpost #title:active,
#blogpost #title:focus {
background-color: var(--bg-3);
}
#blogpost #publish-date {
padding: .4em .5em;
font-family: inherit;
font-size: inherit;
border-radius: 4px;
border: none;
background-color: var(--bg-2);
color: var(--fg-3);
box-shadow: var(--shadow-sm);
transition: background-color .1s ease-out, color .1s ease-out;
}
#blogpost textarea {
width: calc(100% - 2em);
margin: 0;
padding: 1em;
display: block;
border: none;
border-radius: 4px;
background-color: var(--bg-2);
color: var(--fg-3);
box-shadow: var(--shadow-md);
resize: vertical;
outline: none;
transition: background-color .1s ease-out, color .1s ease-out;
}
#blogpost #description {
font-family: inherit;
}
#blogpost select {
padding: .5em .8em;
font-size: inherit;
border: none;
border-radius: 10em;
color: var(--fg-3);
background-color: var(--bg-2);
box-shadow: var(--shadow-sm);
transition: background-color .1s ease-out, color .1s ease-out;
}
#blogpost .social-post-details {
margin: 1em 0 1em 0;
display: flex;
gap: 1em;
}
#blogpost .blog-actions {
margin-top: 1em;
}
@media (prefers-color-scheme: dark) {
input[type="text"],
#blogpost #publish-date,
#blogpost textarea,
#blogpost select {
background-color: var(--bg-0);
}
}

83
admin/static/edit-blog.js Normal file
View file

@ -0,0 +1,83 @@
const blogID = document.getElementById("blogpost").dataset.id;
const titleInput = document.getElementById("title");
const publishDateInput = document.getElementById("publish-date");
const setCurrentDateBtn = document.getElementById("set-current-date");
const descInput = document.getElementById("description");
const mdInput = document.getElementById("markdown");
const blueskyActorInput = document.getElementById("bluesky-actor");
const blueskyRecordInput = document.getElementById("bluesky-record");
const fediverseAccountInput = document.getElementById("fediverse-account");
const fediverseStatusInput = document.getElementById("fediverse-status");
const visInput = document.getElementById("visibility");
const saveBtn = document.getElementById("save");
const deleteBtn = document.getElementById("delete");
setCurrentDateBtn.addEventListener("click", () => {
let now = new Date;
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
publishDateInput.value = now.toISOString().slice(0, 16);
saveBtn.disabled = false;
});
saveBtn.addEventListener("click", () => {
fetch("/api/v1/blog/" + blogID, {
method: "PUT",
body: JSON.stringify({
title: titleInput.innerText,
publish_date: publishDateInput.value + ":00Z",
description: descInput.value,
markdown: mdInput.value,
bluesky: {
actor: blueskyActorInput.value,
record: blueskyRecordInput.value,
},
fediverse: {
account: fediverseAccountInput.value,
status: fediverseStatusInput.value,
},
visible: visInput.value === "true",
}),
headers: { "Content-Type": "application/json" }
}).then(res => {
if (!res.ok) {
res.text().then(error => {
console.error(error);
alert("Failed to update blog post: " + error);
});
return;
}
location = location;
});
});
deleteBtn.addEventListener("click", () => {
if (blogID != prompt(
"You are about to permanently delete " + blogID + ". " +
"This action is irreversible. " +
"Please enter \"" + blogID + "\" to continue.")) return;
fetch("/api/v1/blog/" + blogID, {
method: "DELETE",
}).then(res => {
if (!res.ok) {
res.text().then(error => {
console.error(error);
alert("Failed to delete blog post: " + error);
});
return;
}
location = "/admin";
});
});
[titleInput, publishDateInput, descInput, mdInput, visInput,
blueskyActorInput, blueskyRecordInput,
fediverseAccountInput, fediverseStatusInput].forEach(input => {
input.addEventListener("change", () => {
saveBtn.disabled = false;
});
input.addEventListener("keypress", () => {
saveBtn.disabled = false;
});
});

View file

@ -12,7 +12,7 @@ input[type="text"] {
gap: 1.2em;
border-radius: 8px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@ -25,6 +25,8 @@ input[type="text"] {
.release-artwork img {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
box-shadow: var(--shadow-md);
}
.release-artwork img:hover {
outline: 1px solid #808080;
@ -33,7 +35,10 @@ input[type="text"] {
.release-artwork #remove-artwork {
margin-top: .5em;
padding: .3em .6em;
background: var(--bg-3);
background-color: var(--bg-3);
}
#remove-artwork:hover {
background-color: var(--fg-3);
}
.release-info {
@ -62,13 +67,13 @@ input[type="text"] {
}
#title:hover {
background: var(--bg-3);
background-color: var(--bg-3);
border-color: var(--fg-0);
}
#title:active,
#title:focus {
background: var(--bg-3);
background-color: var(--bg-3);
}
.release-title small {
@ -93,7 +98,7 @@ input[type="text"] {
.release-info table tr td:not(:first-child) select:hover,
.release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover {
background: var(--bg-3);
background-color: var(--bg-3);
cursor: pointer;
}
.release-info table td select,
@ -127,7 +132,7 @@ input[type="text"] {
.release-actions button,
.release-actions .button {
color: var(--fg-2);
background: var(--bg-3);
background-color: var(--bg-3);
}
dialog {
@ -234,7 +239,7 @@ dialog div.dialog-actions {
gap: 1em;
border-radius: 8px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -280,7 +285,7 @@ dialog div.dialog-actions {
border: none;
border-radius: 4px;
color: var(--fg-2);
background: var(--bg-0);
background-color: var(--bg-0);
}
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
@ -299,6 +304,7 @@ dialog div.dialog-actions {
#editcredits .credit .delete {
margin-right: .5em;
cursor: pointer;
overflow: visible;
}
#editcredits .credit .delete:hover {
text-decoration: underline;
@ -315,14 +321,17 @@ dialog div.dialog-actions {
display: flex;
gap: .5em;
cursor: pointer;
background-color: var(--bg-2);
}
#addcredit ul li.new-artist:nth-child(even) {
background: #f0f0f0;
background-color: var(--bg-1);
}
#addcredit ul li.new-artist:hover {
background: #e0e0e0;
background-color: var(--bg-2);
}
#addcredit .new-artist .artist-id {
@ -375,6 +384,8 @@ dialog div.dialog-actions {
#editlinks tr {
display: flex;
background-color: var(--bg-1);
transition: background-color .1s ease-out;
}
#editlinks th {
@ -385,7 +396,7 @@ dialog div.dialog-actions {
}
#editlinks tr:nth-child(odd) {
background: #f8f8f8;
background-color: var(--bg-2);
}
#editlinks tr th,
@ -416,6 +427,11 @@ dialog div.dialog-actions {
width: 1em;
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
#editlinks tr .grabber img {
filter: invert();
}
}
#editlinks tr .link-name {
width: 8em;
}
@ -454,6 +470,7 @@ dialog div.dialog-actions {
}
#edittracks .track {
background-color: var(--bg-1);
transition: transform .2s ease-out, opacity .2s;
}
@ -476,7 +493,7 @@ dialog div.dialog-actions {
}
#edittracks .track:nth-child(even) {
background: #f0f0f0;
background-color: var(--bg-0);
}
#edittracks .track-number {
@ -492,24 +509,23 @@ dialog div.dialog-actions {
#addtrack ul {
padding: 0;
list-style: none;
background: #f8f8f8;
}
#addtrack ul li.new-track {
padding: .5em;
display: flex;
gap: .5em;
background-color: var(--bg-0);
background-color: var(--bg-1);
cursor: pointer;
transition: background-color .1s ease-out, color .1s ease-out;
}
#addtrack ul li.new-track:nth-child(even) {
background: color-mix(in srgb, var(--bg-0) 95%, #fff);
background-color: var(--bg-0);
}
#addtrack ul li.new-track:hover {
background: color-mix(in srgb, var(--bg-0) 90%, #fff);
background-color: var(--bg-2);
}
@media only screen and (max-width: 1105px) {

View file

@ -8,7 +8,7 @@
gap: 1.2em;
border-radius: 16px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -45,25 +45,13 @@
font-weight: inherit;
font-family: inherit;
font-size: inherit;
background: var(--bg-0);
background-color: var(--bg-0);
border: none;
border-radius: 4px;
outline: none;
color: inherit;
}
.track-info input[type="text"]:hover,
.track-info textarea:hover {
border-color: #80808080;
}
.track-info input[type="text"]:active,
.track-info textarea:active,
.track-info input[type="text"]:focus,
.track-info textarea:focus {
border-color: #808080;
}
.track-actions {
margin-top: 1em;
display: flex;

1
admin/static/index.css Normal file
View file

@ -0,0 +1 @@
@import url("/admin/static/release-list-item.css");

View file

@ -8,7 +8,7 @@ form#search-form {
padding: 1em;
border-radius: 16px;
color: var(--fg-0);
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
@ -23,7 +23,7 @@ div#search {
border: none;
border-radius: 16px;
color: var(--fg-1);
background: var(--bg-0);
background-color: var(--bg-0);
box-shadow: var(--shadow-sm);
}
@ -100,8 +100,8 @@ td.log-content {
#logs .log.warn {
color: var(--col-on-warn);
background: var(--col-warn);
background-color: var(--col-warn);
}
#logs .log.warn:hover {
background: var(--col-warn-hover);
background-color: var(--col-warn-hover);
}

82
admin/static/music.css Normal file
View file

@ -0,0 +1,82 @@
@import url("/admin/static/release-list-item.css");
.artist {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist:hover {
text-decoration: hover;
}
.artist-avatar {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 100%;
}
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.track p {
margin: 0;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}

View file

@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/releases/" + id;
if (res.ok) location = "/admin/music/releases/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => {
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artists/" + id;
location = "/admin/music/artists/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => {
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/tracks/" + text;
location = "/admin/music/tracks/" + text;
} else {
alert("Request failed: " + text);
console.error(text);

View file

@ -6,7 +6,7 @@
gap: 1em;
border-radius: 16px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@ -54,27 +54,3 @@
flex-wrap: wrap;
gap: .5em;
}
.release .release-actions {
margin-top: .5em;
user-select: none;
color: var(--fg-3);
}
.release .release-actions a {
margin-right: .3em;
padding: .3em .5em;
display: inline-block;
border-radius: 4px;
background: var(--bg-3);
box-shadow: var(--shadow-sm);
transition: color .1s ease-out, background .1s ease-out;
}
.release .release-actions a:hover {
background: var(--bg-0);
color: var(--fg-3);
text-decoration: none;
}

25
admin/static/releases.js Normal file
View file

@ -0,0 +1,25 @@
document.addEventListener('readystatechange', () => {
const newReleaseBtn = document.getElementById("create-release");
if (newReleaseBtn) newReleaseBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this release:");
if (id == null || id == "") return;
fetch("/api/v1/music", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/music/releases/" + id;
else {
res.text().then(err => {
alert(err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});
});

View file

@ -12,7 +12,7 @@
gap: .5em;
border-radius: 16px;
background: var(--bg-2);
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
@ -44,11 +44,6 @@
opacity: .5;
}
#tracks .track-album.empty {
color: #ff2020;
opacity: 1;
}
#tracks .track-description {
font-style: italic;
}
@ -67,61 +62,4 @@
margin: 0;
display: flex;
flex-direction: row;
/*
justify-content: space-between;
*/
}
/*
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: color .1s ease-out, background-color .1s ease-out;
}
.track p {
margin: 0;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}
*/

24
admin/static/tracks.js Normal file
View file

@ -0,0 +1,24 @@
const newTrackBtn = document.getElementById("create-track");
if (newTrackBtn) newTrackBtn.addEventListener("click", event => {
event.preventDefault();
const title = prompt("Enter an title for this track:");
if (title == null || title == "") return;
fetch("/api/v1/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({title})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/music/tracks/" + text;
} else {
alert(text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create track. Check the console for details.");
console.error(err);
});
});

View file

@ -0,0 +1,33 @@
{{define "head"}}
<title>Blog - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/blog.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Blog Posts <small>({{.TotalPosts}} total)</small></h2>
<a class="button new" id="create-post">Create New</a>
</header>
{{if .Collections}}
<div class="blog-group">
{{range .Collections}}
{{if .Posts}}
<div class="blog-collection">
<h2>{{.Year}}</h2>
{{range .Posts}}
{{block "blogpost" .}}{{end}}
{{end}}
</div>
{{end}}
{{end}}
</div>
{{else}}
<p>There are no blog posts.</p>
{{end}}
</main>
<script type="module" src="/admin/static/blog.js"></script>
{{end}}

View file

@ -1,6 +1,6 @@
{{define "artist"}}
<div class="artist">
<img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
<a href="/admin/music/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
</div>
{{end}}

View file

@ -0,0 +1,14 @@
{{define "blogpost"}}
<div class="blogpost">
<h3 class="title"><a href="/admin/blogs/{{.ID}}">{{.Title}}</a>{{if not .Visible}} <small>(Not published)</small>{{end}}</h3>
<p class="meta">
<span class="author"><img src="/img/favicon.png" alt="" width="32" height="32"/> {{.Author.DisplayName}}</span>
<span class="date">&bull; {{.PrintDate}}</span>
</p>
<p class="description">{{.Description}}</p>
<div class="actions">
<a href="/admin/blogs/{{.ID}}">Edit</a>
<a href="/blog/{{.ID}}">View</a>
</div>
</div>
{{end}}

View file

@ -7,7 +7,7 @@
{{range $Artist := .Artists}}
<li class="new-artist"
data-id="{{$Artist.ID}}"
hx-get="/admin/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-get="/admin/music/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-target="#editcredits ul"
hx-swap="beforeend"
>

View file

@ -3,8 +3,8 @@
<h2>Editing: Credits</h2>
<a id="add-credit"
class="button new"
href="/admin/releases/{{.ID}}/addcredit"
hx-get="/admin/releases/{{.ID}}/addcredit"
href="/admin/music/releases/{{.ID}}/addcredit"
hx-get="/admin/music/releases/{{.ID}}/addcredit"
hx-target="body"
hx-swap="beforeend"
>Add</a>

View file

@ -5,7 +5,7 @@
</div>
<div class="release-info">
<h3 class="release-title">
<a href="/admin/releases/{{.ID}}">{{.Title}}</a>
<a href="/admin/music/releases/{{.ID}}">{{.Title}}</a>
<small>
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
{{if not .Visible}}(hidden){{end}}
@ -13,9 +13,9 @@
</h3>
<p class="release-artists">{{.PrintArtists true true}}</p>
<p class="release-type-single">{{.ReleaseType}}
(<a href="/admin/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
<div class="release-actions">
<a href="/admin/releases/{{.ID}}">Edit</a>
(<a href="/admin/music/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
<div class="actions">
<a href="/admin/music/releases/{{.ID}}">Edit</a>
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
</div>
</div>

View file

@ -8,7 +8,7 @@
</li>
<li class="new-track"
data-id="{{$Track.ID}}"
hx-get="/admin/releases/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
hx-get="/admin/music/releases/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
hx-target="#edittracks ul"
hx-swap="beforeend"
>

View file

@ -3,8 +3,8 @@
<h2>Editing: Tracks</h2>
<a id="add-track"
class="button new"
href="/admin/releases/{{.Release.ID}}/addtrack"
hx-get="/admin/releases/{{.Release.ID}}/addtrack"
href="/admin/music/releases/{{.Release.ID}}/addtrack"
hx-get="/admin/music/releases/{{.Release.ID}}/addtrack"
hx-target="body"
hx-swap="beforeend"
>Add</a>
@ -12,12 +12,12 @@
<form action="/api/v1/music/{{.Release.ID}}/tracks">
<ul>
{{range $i, $track := .Release.Tracks}}
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
{{range .Release.Tracks}}
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true">
<div>
<p class="track-name">
<span class="track-number">{{.Add $i 1}}</span>
{{$track.Title}}
<span class="track-number">{{.Number}}</span>
{{.Title}}
</p>
<a class="delete">Delete</a>
</div>
@ -49,7 +49,6 @@
deleteBtn.addEventListener("click", e => {
e.preventDefault();
if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return;
trackItem.remove();
refreshTrackNumbers();
});

View file

@ -4,7 +4,7 @@
{{if .Number}}
<span class="track-number">{{.Number}}</span>
{{end}}
<a href="/admin/tracks/{{.ID}}">{{.Title}}</a>
<a href="/admin/music/tracks/{{.ID}}">{{.Title}}</a>
</h2>
<h3>Description</h3>

View file

@ -39,7 +39,7 @@
<div class="credit">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info">
<h3 class="credit-name"><a href="/admin/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<h3 class="credit-name"><a href="/admin/music/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Role}}

View file

@ -0,0 +1,118 @@
{{define "head"}}
<title>Editing {{.Post.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-blog.css">
{{end}}
{{define "content"}}
<main>
<h1>Editing Blog Post</h1>
<div id="blogpost" data-id="{{.Post.ID}}">
<label for="title">Title</label>
<h2 id="blog-title">
<div
id="title"
name="title"
role="textbox"
aria-multiline="true"
spellcheck="true"
aria-haspopup="listbox"
aria-invalid="false"
aria-autocomplete="list"
autocorrect="off"
contenteditable="true"
zindex="-1"
>{{.Post.Title}}</div>
</h2>
<label for="publish-date">Publish Date</label>
<input type="datetime-local" name="publish-date" id="publish-date" value="{{.Post.TextPublishDate}}">
<button type="button" id="set-current-date">Current date</button>
<label for="description">Description</label>
<textarea
id="description"
name="description"
value="{{.Post.Description}}"
placeholder="No description provided."
rows="3"
>{{.Post.Description}}</textarea>
<label for="markdown">Markdown</label>
<textarea
id="markdown"
name="markdown"
value="{{.Post.Markdown}}"
rows="30"
>{{.Post.Markdown}}</textarea>
<div class="social-post-details">
<div class="social-post-item">
<label for="bluesky-actor">Bluesky Author DID</label>
<input
type="text"
name="bluesky-actor"
id="bluesky-actor"
placeholder="did:plc:1234abcd..."
value="{{if .Post.Bluesky}}{{.Post.Bluesky.ActorDID}}{{end}}">
</div>
<div class="social-post-item">
<label for="bluesky-record">Bluesky Post ID</label>
<input
type="text"
name="bluesky-record"
id="bluesky-record"
placeholder="3m109a03..."
value="{{if .Post.Bluesky}}{{.Post.Bluesky.RecordID}}{{end}}">
</div>
</div>
<div class="social-post-details">
<div class="social-post-item">
<label for="fediverse-account">Fediverse Account</label>
<input
type="text"
name="fediverse-account"
id="fediverse-account"
placeholder="@me@my.fediverse.place"
value="{{if .Post.Fediverse}}{{.Post.Fediverse.AccountID}}{{end}}">
</div>
<div class="social-post-item">
<label for="fediverse-status">Fediverse Status ID</label>
<input
type="text"
name="fediverse-status"
id="fediverse-status"
placeholder="never consistent ¯\_(ツ)_/¯"
value="{{if .Post.Fediverse}}{{.Post.Fediverse.StatusID}}{{end}}">
</div>
</div>
<label for="visibility">Visibility</label>
<select name="visibility" id="visibility">
<option value="true"{{if .Post.Visible}} selected{{end}}>Visible</option>
<option value="false"{{if not .Post.Visible}} selected{{end}}>Hidden</option>
</select>
<div class="actions">
<a href="/blog/{{.Post.ID}}" class="button">View</a>
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
<div class="card" id="danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p>
Clicking the button below will delete this blog post.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Post</button>
</div>
</main>
<script type="module" src="/admin/static/edit-blog.js"></script>
{{end}}

View file

@ -8,7 +8,7 @@
{{define "content"}}
<main>
<h1>Editing {{.Release.Title}}</h1>
<h1>Editing Release</h1>
<div id="release" data-id="{{.Release.ID}}">
<div class="release-artwork">
@ -100,21 +100,22 @@
</div>
</div>
<div class="card" id="credits">
<div id="credits" class="card">
<div class="card-header">
<h2>Credits <small>({{len .Release.Credits}} total)</small></h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/editcredits"
hx-get="/admin/releases/{{.Release.ID}}/editcredits"
href="/admin/music/releases/{{.Release.ID}}/editcredits"
hx-get="/admin/music/releases/{{.Release.ID}}/editcredits"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
{{range .Release.Credits}}
<div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info">
<p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-name"><a href="/admin/music/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role">
{{.Role}}
{{if .Primary}}
@ -125,40 +126,49 @@
</div>
{{end}}
{{if not .Release.Credits}}
<p>There are no credits.</p>
<p>This release has no credits.</p>
{{end}}
</div>
<div class="card" id="links">
<div id="links" class="card">
<div class="card-header">
<h2>Links ({{len .Release.Links}})</h2>
<h2>Links <small>({{len .Release.Links}} total)</small></h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/editlinks"
hx-get="/admin/releases/{{.Release.ID}}/editlinks"
href="/admin/music/releases/{{.Release.ID}}/editlinks"
hx-get="/admin/music/releases/{{.Release.ID}}/editlinks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
{{if .Release.Links}}
<ul>
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</ul>
{{else}}
<p>This release has no links.</p>
{{end}}
</div>
<div class="card" id="tracks">
<div class="card-header" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<div id="tracks" class="card">
<div class="card-header">
<h2>Tracks <small>({{len .Release.Tracks}} total)</small></h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/edittracks"
hx-get="/admin/releases/{{.Release.ID}}/edittracks"
href="/admin/music/releases/{{.Release.ID}}/edittracks"
hx-get="/admin/music/releases/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
{{range $i, $track := .Release.Tracks}}
{{range .Release.Tracks}}
{{block "track" .}}{{end}}
{{end}}
{{if not .Release.Tracks}}
<p>This release has no tracks.</p>
{{end}}
</div>
<div class="card" id="danger">

View file

@ -4,6 +4,7 @@
<link rel="stylesheet" href="/admin/static/releases.css">
<link rel="stylesheet" href="/admin/static/artists.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
<link rel="stylesheet" href="/admin/static/blog.css">
{{end}}
{{define "content"}}
@ -13,7 +14,7 @@
<div class="cards">
<div class="card" id="releases">
<div class="card-header">
<h2><a href="/admin/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
<h2><a href="/admin/music/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
<a class="button new" id="create-release">Create New</a>
</div>
{{if .Artists}}
@ -27,7 +28,7 @@
<div class="card" id="artists">
<div class="card-header">
<h2><a href="/admin/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
<h2><a href="/admin/music/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
<a class="button new" id="create-artist">Create New</a>
</div>
{{if .Artists}}
@ -43,7 +44,7 @@
<div class="card" id="tracks">
<div class="card-header">
<h2><a href="/admin/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2>
<h2><a href="/admin/music/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2>
<a class="button new" id="create-track">Create New</a>
</div>
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
@ -52,10 +53,24 @@
{{block "track" .}}{{end}}
{{end}}
</div>
<div class="card" id="blogs">
<div class="card-header">
<h2><a href="/admin/blogs/">Latest Blog Post</a> <small>({{.BlogCount}} total)</small></h2>
<a class="button new" id="create-post">Create New</a>
</div>
{{if .LatestBlogPost}}
{{block "blogpost" .LatestBlogPost}}{{end}}
{{else}}
<p>There are no blog posts.</p>
{{end}}
</div>
</div>
</main>
<script type="module" src="/admin/static/releases.js"></script>
<script type="module" src="/admin/static/artists.js"></script>
<script type="module" src="/admin/static/index.js"></script>
<script type="module" src="/admin/static/tracks.js"></script>
<script type="module" src="/admin/static/blog.js"></script>
{{end}}

View file

@ -27,24 +27,33 @@
<div class="nav-item{{if eq .Path "/logs"}} active{{end}}">
<a href="/admin/logs">logs</a>
</div>
<hr>
<p class="section-label">music</p>
<div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}">
<a href="/admin/releases/">releases</a>
<div class="nav-item{{if hasPrefix .Path "/music/releases"}} active{{end}}">
<a href="/admin/music/releases/">releases</a>
</div>
<div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}">
<a href="/admin/artists/">artists</a>
<div class="nav-item{{if hasPrefix .Path "/music/artists"}} active{{end}}">
<a href="/admin/music/artists/">artists</a>
</div>
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
<a href="/admin/tracks/">tracks</a>
<div class="nav-item{{if hasPrefix .Path "/music/tracks"}} active{{end}}">
<a href="/admin/music/tracks/">tracks</a>
</div>
<hr>
<p class="section-label">blog</p>
<div class="nav-item{{if hasPrefix .Path "/blogs"}} active{{end}}">
<a href="/admin/blogs/">blog</a>
</div>
{{end}}
<div class="flex-fill"></div>
{{if .Session.Account}}
<div class="nav-item{{if eq .Path "/account"}} active{{end}}">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
<div class="nav-item{{if hasPrefix .Path "/account"}} active{{end}}">
<a href="/admin/account/">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>

View file

@ -21,4 +21,6 @@
<p>There are no releases.</p>
{{end}}
</main>
<script type="module" src="/admin/static/releases.js"></script>
{{end}}

View file

@ -12,22 +12,8 @@
</header>
<div id="tracks">
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/tracks/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{range .Tracks}}
{{block "track" .}}{{end}}
{{end}}
</div>
</main>

View file

@ -17,63 +17,9 @@ var prideflagHTML string
//go:embed "html/index.html"
var indexHTML string
//go:embed "html/register.html"
var registerHTML string
//go:embed "html/login.html"
var loginHTML string
//go:embed "html/login-totp.html"
var loginTotpHTML string
//go:embed "html/totp-confirm.html"
var totpConfirmHTML string
//go:embed "html/totp-setup.html"
var totpSetupHTML string
//go:embed "html/logout.html"
var logoutHTML string
//go:embed "html/logs.html"
var logsHTML string
//go:embed "html/edit-account.html"
var editAccountHTML string
//go:embed "html/releases.html"
var releasesHTML string
//go:embed "html/artists.html"
var artistsHTML string
//go:embed "html/tracks.html"
var tracksHTML string
//go:embed "html/edit-release.html"
var editReleaseHTML string
//go:embed "html/edit-artist.html"
var editArtistHTML string
//go:embed "html/edit-track.html"
var editTrackHTML string
//go:embed "html/components/credit/newcredit.html"
var componentNewCreditHTML string
//go:embed "html/components/credit/addcredit.html"
var componentAddCreditHTML string
//go:embed "html/components/credit/editcredits.html"
var componentEditCreditsHTML string
//go:embed "html/components/link/editlinks.html"
var componentEditLinksHTML string
//go:embed "html/components/release/release.html"
var componentReleaseHTML string
//go:embed "html/components/artist/artist.html"
var componentArtistHTML string
//go:embed "html/components/track/track.html"
var componentTrackHTML string
//go:embed "html/components/track/newtrack.html"
var componentNewTrackHTML string
//go:embed "html/components/track/addtrack.html"
var componentAddTrackHTML string
//go:embed "html/components/track/edittracks.html"
var componentEditTracksHTML string
var BaseTemplate = template.Must(
template.New("base").Funcs(
template.FuncMap{
@ -84,48 +30,102 @@ var BaseTemplate = template.Must(
prideflagHTML,
}, "\n")))
//go:embed "html/components/release/release.html"
var componentReleaseHTML string
//go:embed "html/components/artist/artist.html"
var componentArtistHTML string
//go:embed "html/components/track/track.html"
var componentTrackHTML string
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
indexHTML,
componentReleaseHTML,
componentArtistHTML,
componentTrackHTML,
componentBlogPostHTML,
}, "\n"),
))
//go:embed "html/login.html"
var loginHTML string
var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
//go:embed "html/login-totp.html"
var loginTotpHTML string
var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
//go:embed "html/register.html"
var registerHTML string
var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
//go:embed "html/logout.html"
var logoutHTML string
var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML))
//go:embed "html/edit-account.html"
var editAccountHTML string
var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML))
//go:embed "html/totp-setup.html"
var totpSetupHTML string
var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
//go:embed "html/totp-confirm.html"
var totpConfirmHTML string
var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML))
var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{
"parseLevel": parseLevel,
"titleCase": titleCase,
"toLower": toLower,
"prettyTime": prettyTime,
"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)
},
"toLower": func (str string) string {
return strings.ToLower(str)
},
"prettyTime": func (t time.Time) string {
return t.Format("02 Jan 2006, 15:04:05")
},
}).Parse(logsHTML))
//go:embed "html/releases.html"
var releasesHTML string
var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
releasesHTML,
componentReleaseHTML,
}, "\n"),
))
//go:embed "html/artists.html"
var artistsHTML string
var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
artistsHTML,
componentArtistHTML,
}, "\n"),
))
//go:embed "html/tracks.html"
var tracksHTML string
var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
tracksHTML,
@ -135,13 +135,21 @@ var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
//go:embed "html/edit-release.html"
var editReleaseHTML string
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editReleaseHTML,
componentTrackHTML,
}, "\n"),
))
//go:embed "html/edit-artist.html"
var editArtistHTML string
var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML))
//go:embed "html/edit-track.html"
var editTrackHTML string
var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editTrackHTML,
@ -151,41 +159,54 @@ var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
//go:embed "html/components/credit/newcredit.html"
var componentNewCreditHTML string
var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
//go:embed "html/components/credit/addcredit.html"
var componentAddCreditHTML string
var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
//go:embed "html/components/credit/editcredits.html"
var componentEditCreditsHTML string
var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
//go:embed "html/components/link/editlinks.html"
var componentEditLinksHTML string
var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML))
//go:embed "html/components/track/newtrack.html"
var componentNewTrackHTML string
var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
//go:embed "html/components/track/addtrack.html"
var componentAddTrackHTML string
var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
//go:embed "html/components/track/edittracks.html"
var componentEditTracksHTML string
var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML))
func parseLevel(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
}
func titleCase(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)
}
func toLower(str string) string {
return strings.ToLower(str)
}
func prettyTime(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")
}
//go:embed "html/blogs.html"
var blogsHTML string
//go:embed "html/components/blog/blogpost.html"
var componentBlogPostHTML string
var BlogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
blogsHTML,
componentBlogPostHTML,
}, "\n"),
))
//go:embed "html/edit-blog.html"
var editBlogHTML string
var EditBlogTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editBlogHTML,
componentBlogPostHTML,
}, "\n"),
))

View file

@ -1,14 +1,14 @@
package api
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"context"
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/controller"
"arimelody-web/model"
)
func Handler(app *model.AppState) http.Handler {
@ -18,129 +18,51 @@ func Handler(app *model.AppState) http.Handler {
// ARTIST ENDPOINTS
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artistID = strings.Split(r.URL.Path[1:], "/")[0]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
mux.Handle("GET /v1/artist/{id}", ServeArtist(app))
mux.Handle("PUT /v1/artist/{id}", requireAccount(UpdateArtist(app)))
mux.Handle("DELETE /v1/artist/{id}", requireAccount(DeleteArtist(app)))
switch r.Method {
case http.MethodGet:
// GET /api/v1/artist/{id}
ServeArtist(app, artist).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/artist/{id} (admin)
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin)
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})))
mux.Handle("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// GET /api/v1/artist
ServeAllArtists(app).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/artist (admin)
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}))
mux.Handle("GET /v1/artist/", ServeAllArtists(app))
mux.Handle("GET /v1/artist", ServeAllArtists(app))
mux.Handle("POST /v1/artist/", requireAccount(CreateArtist(app)))
mux.Handle("POST /v1/artist", requireAccount(CreateArtist(app)))
// RELEASE ENDPOINTS
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
mux.Handle("GET /v1/music/{id}", ServeRelease(app))
mux.Handle("PUT /v1/music/{id}", requireAccount(UpdateRelease(app)))
mux.Handle("DELETE /v1/music/{id}", requireAccount(DeleteRelease(app)))
switch r.Method {
case http.MethodGet:
// GET /api/v1/music/{id}
ServeRelease(app, release).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/music/{id} (admin)
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin)
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})))
mux.Handle("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// GET /api/v1/music
ServeCatalog(app).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/music (admin)
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}))
mux.Handle("PUT /v1/music/{id}/tracks", requireAccount(UpdateReleaseTracks(app)))
mux.Handle("PUT /v1/music/{id}/credits", requireAccount(UpdateReleaseCredits(app)))
mux.Handle("PUT /v1/music/{id}/links", requireAccount(UpdateReleaseLinks(app)))
mux.Handle("GET /v1/music/", ServeCatalog(app))
mux.Handle("GET /v1/music", ServeCatalog(app))
mux.Handle("POST /v1/music/", requireAccount(CreateRelease(app)))
mux.Handle("POST /v1/music", requireAccount(CreateRelease(app)))
// TRACK ENDPOINTS
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackID = strings.Split(r.URL.Path[1:], "/")[0]
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
mux.Handle("GET /v1/track/{id}", requireAccount(ServeTrack(app)))
mux.Handle("PUT /v1/track/{id}", requireAccount(UpdateTrack(app)))
mux.Handle("DELETE /v1/track/{id}", requireAccount(DeleteTrack(app)))
switch r.Method {
case http.MethodGet:
// GET /api/v1/track/{id} (admin)
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/track/{id} (admin)
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin)
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})))
mux.Handle("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track (admin)
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/track (admin)
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}))
mux.Handle("GET /v1/track/", requireAccount(ServeAllTracks(app)))
mux.Handle("GET /v1/track", requireAccount(ServeAllTracks(app)))
mux.Handle("POST /v1/track/", requireAccount(CreateTrack(app)))
mux.Handle("POST /v1/track", requireAccount(CreateTrack(app)))
// BLOG ENDPOINTS
mux.Handle("GET /v1/blog/{id}", ServeBlog(app))
mux.Handle("PUT /v1/blog/{id}", requireAccount(UpdateBlog(app)))
mux.Handle("DELETE /v1/blog/{id}", requireAccount(DeleteBlog(app)))
mux.Handle("GET /v1/blog/", ServeAllBlogs(app))
mux.Handle("GET /v1/blog", ServeAllBlogs(app))
mux.Handle("POST /v1/blog/", requireAccount(CreateBlog(app)))
mux.Handle("POST /v1/blog", requireAccount(CreateBlog(app)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := getSession(app, r)

View file

@ -35,8 +35,20 @@ func ServeAllArtists(app *model.AppState) http.Handler {
})
}
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
func ServeArtist(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artistID = r.PathValue("id")
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if artist == nil {
http.NotFound(w, r)
return
}
type (
creditJSON struct {
ID string `json:"id"`
@ -99,7 +111,7 @@ func CreateArtist(app *model.AppState) http.Handler {
}
if artist.ID == "" {
http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest)
http.Error(w, "Artist ID cannot be blank", http.StatusBadRequest)
return
}
if artist.Name == "" { artist.Name = artist.ID }
@ -107,7 +119,7 @@ func CreateArtist(app *model.AppState) http.Handler {
err = controller.CreateArtist(app.DB, &artist)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
http.Error(w, fmt.Sprintf("Artist %s already exists", artist.ID), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err)
@ -121,11 +133,23 @@ func CreateArtist(app *model.AppState) http.Handler {
})
}
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
func UpdateArtist(app *model.AppState) 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)
var artistID = r.PathValue("id")
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if artist == nil {
http.NotFound(w, r)
return
}
err = json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
fmt.Printf("WARN: Failed to update artist: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -158,10 +182,6 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
err = controller.UpdateArtist(app.DB, artist)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -170,16 +190,24 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
})
}
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
func DeleteArtist(app *model.AppState) 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)
var artistID = r.PathValue("id")
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if artist == nil {
http.NotFound(w, r)
return
}
err = controller.DeleteArtist(app.DB, artist.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}

256
api/blog.go Normal file
View file

@ -0,0 +1,256 @@
package api
import (
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
func ServeAllBlogs(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
onlyVisible := true
if session != nil && session.Account != nil {
onlyVisible = false
}
posts, err := controller.GetBlogPosts(app.DB, onlyVisible, -1, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type (
ShortBlogPost struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Author *model.BlogAuthor `json:"author"`
PublishDate time.Time `json:"publish_date"`
}
)
resPosts := []*ShortBlogPost{}
for _, post := range posts {
resPosts = append(resPosts, &ShortBlogPost{
ID: post.ID,
Title: post.Title,
Description: post.Description,
Author: &post.Author,
PublishDate: post.PublishDate,
})
}
err = json.NewEncoder(w).Encode(resPosts)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog posts: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func ServeBlog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
privileged := session != nil && session.Account != nil
blogID := r.PathValue("id")
blog, err := controller.GetBlogPost(app.DB, blogID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if blog == nil || (!blog.Visible && !privileged) {
http.NotFound(w, r)
return
}
blog.Author.ID = blog.Author.DisplayName
err = json.NewEncoder(w).Encode(blog)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func CreateBlog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var blog model.BlogPost
err := json.NewDecoder(r.Body).Decode(&blog)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if blog.ID == "" {
http.Error(w, "Post ID cannot be empty", http.StatusBadRequest)
return
}
if blog.Title == "" { blog.Title = blog.ID }
if blog.PublishDate.Equal(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)) {
blog.PublishDate = time.Date(
time.Now().Year(), time.Now().Month(), time.Now().Day(),
time.Now().Hour(), time.Now().Minute(), 0, 0, time.UTC)
}
blog.Author.ID = session.Account.ID
err = controller.CreateBlogPost(app.DB, &blog)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Post %s already exists", blog.ID), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to create blog %s: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" created by \"%s\".", blog.ID, session.Account.Username)
blog.Author.ID = session.Account.Username
blog.Author.DisplayName = session.Account.Username
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog)
if err != nil {
fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func UpdateBlog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
blogID := r.PathValue("id")
blog, err := controller.GetBlogPost(app.DB, blogID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type (
BlueskyRecord struct {
ActorID string `json:"actor"`
RecordID string `json:"record"`
}
FediverseStatus struct {
AccountID string `json:"account"`
StatusID string `json:"status"`
}
UpdatedBlog struct {
Title string `json:"title"`
PublishDate time.Time `json:"publish_date"`
Description string `json:"description"`
Markdown string `json:"markdown"`
Bluesky BlueskyRecord `json:"bluesky"`
Fediverse FediverseStatus `json:"fediverse"`
Visible bool `json:"visible"`
}
)
var updatedBlog UpdatedBlog
err = json.NewDecoder(r.Body).Decode(&updatedBlog)
if err != nil {
fmt.Printf("WARN: Failed to update blog %s: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
blog.Title = updatedBlog.Title
blog.PublishDate = updatedBlog.PublishDate
blog.Description = updatedBlog.Description
blog.Markdown = updatedBlog.Markdown
if len(updatedBlog.Bluesky.ActorID) > 0 && len(updatedBlog.Bluesky.RecordID) > 0 {
blog.Bluesky = &model.BlueskyRecord{
ActorDID: updatedBlog.Bluesky.ActorID,
RecordID: updatedBlog.Bluesky.RecordID,
}
} else {
blog.Bluesky = nil
}
if len(updatedBlog.Fediverse.AccountID) > 0 && len(updatedBlog.Fediverse.StatusID) > 0 {
blog.Fediverse = &model.FediverseActivity{
AccountID: updatedBlog.Fediverse.AccountID,
StatusID: updatedBlog.Fediverse.StatusID,
}
} else {
blog.Fediverse = nil
}
blog.Visible = updatedBlog.Visible
err = controller.UpdateBlogPost(app.DB, blogID, blog)
if err != nil {
fmt.Printf("WARN: Failed to update release %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" updated by \"%s\".", blog.ID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog)
if err != nil {
fmt.Printf("WARN: Blog post %s updated, but failed to send JSON response: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func DeleteBlog(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
blogID := r.PathValue("id")
blog, err := controller.GetBlogPost(app.DB, blogID)
if err != nil {
fmt.Printf("WARN: Failed to fetch blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if blog == nil {
http.NotFound(w, r)
return
}
err = controller.DeleteBlogPost(app.DB, blogID)
if err != nil {
fmt.Printf("WARN: Failed to delete blog post %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" deleted by \"%s\".", blogID, session.Account.Username)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(blog)
if err != nil {
fmt.Printf("WARN: Blog post %s deleted, but failed to send JSON response: %v\n", blog.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}

View file

@ -15,8 +15,20 @@ import (
"arimelody-web/model"
)
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
func ServeRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var releaseID = r.PathValue("id")
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
// only allow authorised users to view hidden releases
privileged := false
if !release.Visible {
@ -119,7 +131,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
err := encoder.Encode(response)
err = encoder.Encode(response)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -200,7 +212,7 @@ func CreateRelease(app *model.AppState) http.Handler {
}
if release.ID == "" {
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
http.Error(w, "Release ID cannot be empty", http.StatusBadRequest)
return
}
@ -216,7 +228,7 @@ func CreateRelease(app *model.AppState) http.Handler {
err = controller.CreateRelease(app.DB, &release)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
http.Error(w, fmt.Sprintf("Release %s already exists", release.ID), http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err)
@ -238,35 +250,23 @@ func CreateRelease(app *model.AppState) http.Handler {
})
}
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
func UpdateRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if r.URL.Path == "/" {
var releaseID = r.PathValue("id")
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
segments := strings.Split(r.URL.Path[1:], "/")
if len(segments) == 2 {
switch segments[1] {
case "tracks":
UpdateReleaseTracks(app, release).ServeHTTP(w, r)
case "credits":
UpdateReleaseCredits(app, release).ServeHTTP(w, r)
case "links":
UpdateReleaseLinks(app, release).ServeHTTP(w, r)
}
return
}
if len(segments) > 2 {
http.NotFound(w, r)
return
}
err := json.NewDecoder(r.Body).Decode(&release)
err = json.NewDecoder(r.Body).Decode(&release)
if err != nil {
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -299,10 +299,6 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
err = controller.UpdateRelease(app.DB, release)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -311,12 +307,24 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
})
}
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
func UpdateReleaseTracks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var releaseID = r.PathValue("id")
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
err = json.NewDecoder(r.Body).Decode(&trackIDs)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
@ -324,8 +332,8 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Release cannot have duplicate tracks", http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
@ -336,17 +344,29 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
})
}
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
func UpdateReleaseCredits(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var releaseID = r.PathValue("id")
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
type creditJSON struct {
Artist string
Role string
Primary bool
}
var data []creditJSON
err := json.NewDecoder(r.Body).Decode(&data)
err = json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
@ -366,14 +386,10 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
http.Error(w, "Artists may only be credited once", http.StatusBadRequest)
return
}
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to update credits for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -381,12 +397,24 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
})
}
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
func UpdateReleaseLinks(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var releaseID = r.PathValue("id")
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
var links = []*model.Link{}
err := json.NewDecoder(r.Body).Decode(&links)
err = json.NewDecoder(r.Body).Decode(&links)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
@ -394,8 +422,8 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Release cannot have duplicate link names", http.StatusBadRequest)
return
}
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
@ -406,16 +434,24 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
})
}
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
func DeleteRelease(app *model.AppState) 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)
var releaseID = r.PathValue("id")
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if release == nil {
http.NotFound(w, r)
return
}
err = controller.DeleteRelease(app.DB, release.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}

View file

@ -1,13 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"encoding/json"
"fmt"
"net/http"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
)
type (
@ -50,8 +50,20 @@ func ServeAllTracks(app *model.AppState) http.Handler {
})
}
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
func ServeTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackID = r.PathValue("id")
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if track == nil {
http.NotFound(w, r)
return
}
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
@ -86,7 +98,7 @@ func CreateTrack(app *model.AppState) http.Handler {
}
if track.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
http.Error(w, "Track title cannot be empty", http.StatusBadRequest)
return
}
@ -105,23 +117,30 @@ func CreateTrack(app *model.AppState) http.Handler {
})
}
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
func UpdateTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
session := r.Context().Value("session").(*model.Session)
var trackID = r.PathValue("id")
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if track == nil {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := json.NewDecoder(r.Body).Decode(&track)
err = json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if track.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
http.Error(w, "Track title cannot be empty", http.StatusBadRequest)
return
}
@ -144,17 +163,23 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
})
}
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
func DeleteTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
session := r.Context().Value("session").(*model.Session)
var trackID = r.PathValue("id")
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if track == nil {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(app.DB, trackID)
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)

View file

@ -23,9 +23,7 @@ func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
@ -37,9 +35,7 @@ func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error)
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
@ -51,9 +47,7 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
@ -67,9 +61,7 @@ func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, erro
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}

View file

@ -1,9 +1,10 @@
package controller
import (
"arimelody-web/model"
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
// DATABASE
@ -13,6 +14,7 @@ func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}

197
controller/blog.go Normal file
View file

@ -0,0 +1,197 @@
package controller
import (
"arimelody-web/model"
"database/sql"
"strings"
"github.com/jmoiron/sqlx"
)
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
var blog = model.BlogPost{}
rows, err := db.Query(
"SELECT post.id,post.title,post.description,post.visible," +
"post.publish_date,post.author,post.markdown," +
"post.bluesky_actor,post.bluesky_record," +
"post.fediverse_account,post.fediverse_status," +
"author.id,author.username,author.avatar_url " +
"FROM blogpost AS post " +
"JOIN account AS author ON post.author=author.id " +
"WHERE post.id=$1",
id,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
defer rows.Close()
blueskyActor := sql.NullString{}
blueskyRecord := sql.NullString{}
fediverseAccount := sql.NullString{}
fediverseStatus := sql.NullString{}
if !rows.Next() {
return nil, nil
}
err = rows.Scan(
&blog.ID, &blog.Title, &blog.Description, &blog.Visible,
&blog.PublishDate, &blog.Author.ID, &blog.Markdown,
&blueskyActor, &blueskyRecord,
&fediverseAccount, &fediverseStatus,
&blog.Author.ID, &blog.Author.DisplayName, &blog.Author.AvatarURL,
)
if err != nil {
return nil, err
}
if blueskyActor.Valid && blueskyRecord.Valid {
blog.Bluesky = &model.BlueskyRecord{
ActorDID: blueskyActor.String,
RecordID: blueskyRecord.String,
}
}
if fediverseAccount.Valid && fediverseStatus.Valid {
blog.Fediverse = &model.FediverseActivity{
AccountID: fediverseAccount.String,
StatusID: fediverseStatus.String,
}
}
return &blog, nil
}
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
var blogs = []*model.BlogPost{}
query := "SELECT post.id,post.title,post.publish_date," +
"post.description,post.visible," +
"author.id,author.username,author.avatar_url " +
"FROM blogpost AS post " +
"JOIN account AS author ON post.author=author.id"
if onlyVisible { query += " WHERE visible=true" }
query += " ORDER BY publish_date DESC"
var rows *sql.Rows
var err error
if limit < 0 {
rows, err = db.Query(query)
} else {
rows, err = db.Query(query + " LIMIT $1 OFFSET $2", limit, offset)
}
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
blog := model.BlogPost{}
err = rows.Scan(
&blog.ID,
&blog.Title,
&blog.PublishDate,
&blog.Description,
&blog.Visible,
&blog.Author.ID,
&blog.Author.DisplayName,
&blog.Author.AvatarURL,
)
if err != nil {
return nil, err
}
blogs = append(blogs, &blog)
}
return blogs, nil
}
func GetBlogPostCount(db *sqlx.DB, onlyVisible bool) (int, error) {
query := "SELECT count(*) FROM blogpost"
if onlyVisible {
query += " WHERE visible=true"
}
var count int
err := db.Get(&count, query)
return count, err
}
func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error {
var blueskyActor *string
var blueskyRecord *string
if post.Bluesky != nil {
blueskyActor = &post.Bluesky.ActorDID
blueskyRecord = &post.Bluesky.RecordID
}
var fediverseAccount *string
var fediverseStatus *string
if post.Fediverse != nil {
fediverseAccount = &post.Fediverse.AccountID
fediverseStatus = &post.Fediverse.StatusID
}
_, err := db.Exec(
"INSERT INTO blogpost (" +
"id,title,description,visible," +
"publish_date,author,markdown," +
"bluesky_actor,bluesky_record," +
"fediverse_account,fediverse_status) " +
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
post.ID, post.Title, post.Description, post.Visible,
post.PublishDate, post.Author.ID, post.Markdown,
blueskyActor, blueskyRecord,
fediverseAccount, fediverseStatus,
)
return err
}
func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error {
var blueskyActor string
var blueskyRecord string
if post.Bluesky != nil {
blueskyActor = post.Bluesky.ActorDID
blueskyRecord = post.Bluesky.RecordID
}
var fediverseAccount string
var fediverseStatus string
if post.Fediverse != nil {
fediverseAccount = post.Fediverse.AccountID
fediverseStatus = post.Fediverse.StatusID
}
_, err := db.Exec(
"UPDATE blogpost SET " +
"id=$2,title=$3,description=$4,visible=$5," +
"publish_date=$6,author=$7,markdown=$8," +
"bluesky_actor=$9,bluesky_record=$10," +
"fediverse_account=$11,fediverse_status=$12 " +
"WHERE id=$1",
postID,
post.ID, post.Title, post.Description, post.Visible,
post.PublishDate, post.Author.ID, post.Markdown,
blueskyActor, blueskyRecord,
fediverseAccount, fediverseStatus,
)
return err
}
func DeleteBlogPost(db *sqlx.DB, postID string) error {
_, err := db.Exec(
"DELETE FROM blogpost "+
"WHERE id=$1",
postID,
)
if err != nil {
return err
}
return nil
}

47
controller/bluesky.go Normal file
View file

@ -0,0 +1,47 @@
package controller
import (
"arimelody-web/model"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
const BSKY_API_BASE = "https://public.api.bsky.app"
func FetchThreadViewPost(app *model.AppState, actorID string, recordID string) (*model.ThreadViewPost, error) {
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, recordID)
req, err := http.NewRequest(
http.MethodGet,
strings.Join([]string{BSKY_API_BASE, "xrpc", "app.bsky.feed.getPostThread"}, "/"),
nil,
)
if err != nil { panic(err) }
req.URL.RawQuery = url.Values{
"uri": { uri },
}.Encode()
req.Header.Set("User-Agent", fmt.Sprintf("ari melody [%s]", app.Config.BaseUrl))
req.Header.Set("Accept", "application/json")
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Failed to call Bluesky API: %v", err)
}
type Data struct {
Thread model.ThreadViewPost `json:"thread"`
}
data := Data{}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
return nil, fmt.Errorf("Invalid response from server: %v", err)
}
return &data.Thread, nil
}

View file

@ -16,9 +16,7 @@ func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) {
err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
@ -32,7 +30,7 @@ func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invit
}
code := []byte{}
for i := 0; i < length; i++ {
for range length {
code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)])
}
invite.Code = string(code)

View file

@ -9,7 +9,7 @@ import (
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 4
const DB_VERSION int = 5
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
@ -50,6 +50,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "003-fail-lock")
oldDBVersion = 4
case 4:
ApplyMigration(db, "004-blog")
oldDBVersion = 5
}
}

View file

@ -1,11 +1,12 @@
package controller
import (
"fmt"
"fmt"
"strings"
"arimelody-web/model"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
@ -13,6 +14,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
err := db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
@ -111,6 +113,45 @@ func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) {
return count, err
}
func GetLatestRelease(db *sqlx.DB) (*model.Release, error) {
var release = model.Release{}
err := db.Get(&release, "SELECT * FROM musicrelease WHERE visible=true ORDER BY release_date DESC LIMIT 1")
if err != nil {
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
// get credits
credits, err := GetReleaseCredits(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Credits: %s", err)
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
}
// get tracks
tracks, err := GetReleaseTracks(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Tracks: %s", err)
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
}
// get links
links, err := GetReleaseLinks(db, release.ID)
if err != nil {
return nil, fmt.Errorf("Links: %s", err)
}
for _, link := range links {
release.Links = append(release.Links, link)
}
return &release, nil
}
func CreateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"INSERT INTO musicrelease "+
@ -298,6 +339,7 @@ func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) {
if err != nil {
return nil, err
}
defer rows.Close()
var credits []*model.Credit
for rows.Next() {

View file

@ -127,17 +127,39 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
CREATE TABLE arimelody.blogpost (
id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL,
visible BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
author UUID NOT NULL,
markdown TEXT NOT NULL,
bluesky_actor TEXT,
bluesky_record TEXT,
fediverse_account TEXT,
fediverse_status TEXT
);
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id);
--
-- Foreign keys
--
-- Account
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
-- Music
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(id) ON DELETE CASCADE;
-- Blog
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -0,0 +1,15 @@
CREATE TABLE arimelody.blogpost (
id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL,
visible BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
author UUID NOT NULL,
markdown TEXT NOT NULL,
bluesky_actor TEXT,
bluesky_record TEXT,
fediverse_account TEXT,
fediverse_status TEXT
);
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id);
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -121,9 +121,7 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
name,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}

View file

@ -1,9 +1,10 @@
package controller
import (
"arimelody-web/model"
"arimelody-web/model"
"strings"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
// DATABASE
@ -11,9 +12,9 @@ import (
func GetTrack(db *sqlx.DB, id string) (*model.Track, error) {
var track = model.Track{}
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
err := stmt.Get(&track, id)
err := db.Get(&track, "SELECT * FROM musictrack WHERE id=$1", id)
if err != nil {
if strings.Contains(err.Error(), "no rows") { return nil, nil }
return nil, err
}
return &track, nil

1
go.mod
View file

@ -10,6 +10,7 @@ require (
require golang.org/x/crypto v0.27.0 // indirect
require (
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect

2
go.sum
View file

@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=

View file

@ -114,17 +114,6 @@ func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, conten
conditions,
)
/*
fmt.Printf("%s (", query)
for i, param := range params {
fmt.Print(param)
if i < len(params) - 1 {
fmt.Print(", ")
}
}
fmt.Print(")\n")
*/
err := self.DB.Select(&logs, query, params...)
if err != nil {
return nil, err

23
main.go
View file

@ -517,7 +517,15 @@ func main() {
go cursor.StartCursor(&app)
// start the web server!
mux := createServeMux(&app)
mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(&app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(&app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(&app)))
mux.Handle("/blog/", http.StripPrefix("/blog", view.BlogHandler(&app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(&app))
mux.Handle("/", view.IndexHandler(&app))
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
stdLog.Fatal(
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
@ -525,19 +533,6 @@ func main() {
))
}
func createServeMux(app *model.AppState) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", view.IndexHandler(app))
return mux
}
var PoweredByStrings = []string{
"nerd rage",
"estrogen",

63
model/blog.go Normal file
View file

@ -0,0 +1,63 @@
package model
import (
"fmt"
"regexp"
"strings"
"time"
)
type (
BlueskyRecord struct {
ActorDID string `json:"actor"`
RecordID string `json:"record"`
}
FediverseActivity struct {
AccountID string `json:"account"`
StatusID string `json:"status"`
}
BlogAuthor struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
BlogPost struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Visible bool `json:"visible"`
PublishDate time.Time `json:"publish_date"`
Author BlogAuthor `json:"author"`
Markdown string `json:"markdown"`
Bluesky *BlueskyRecord `json:"bluesky"`
Fediverse *FediverseActivity `json:"fediverse"`
}
)
func (b *BlogPost) TitleNormalised() string {
rgx := regexp.MustCompile(`[^a-z0-9\-]`)
return rgx.ReplaceAllString(
strings.ReplaceAll(
strings.ToLower(b.Title), " ", "-",
),
"",
)
}
func (b *BlogPost) GetMonth() string {
return fmt.Sprintf("%02d", int(b.PublishDate.Month()))
}
func (b *BlogPost) PrintDate() string {
return b.PublishDate.Format("02 January 2006, 15:04")
}
func (b *BlogPost) PrintShortDate() string {
return b.PublishDate.Format("2 Jan 2006")
}
func (b *BlogPost) TextPublishDate() string {
return b.PublishDate.Format("2006-01-02T15:04")
}

82
model/bluesky.go Normal file
View file

@ -0,0 +1,82 @@
package model
import (
"fmt"
"strings"
"time"
)
type (
Record struct {
Type string `json:"$type"`
CreatedAt string `json:"createdAt"`
Text string `json:"text"`
}
Profile struct {
DID string `json:"did"`
Handle string `json:"handle"`
Avatar string `json:"avatar"`
DisplayName string `json:"displayName"`
CreatedAt string `json:"createdAt"`
}
PostImage struct {
Thumbnail string `json:"thumb"`
Fullsize string `json:"fullsize"`
Alt string `json:"alt"`
}
EmbedMedia struct {
Images []PostImage `json:"images"`
}
Embed struct {
Media EmbedMedia `json:"media"`
}
Post struct {
Author Profile `json:"author"`
Record Record `json:"record"`
ReplyCount int `json:"replyCount"`
RepostCount int `json:"repostCount"`
LikeCount int `json:"likeCount"`
QuoteCount int `json:"quoteCount"`
Embed *Embed `json:"embed"`
URI string `json:"uri"`
}
ThreadViewPost struct {
Post Post `json:"post"`
Replies []*ThreadViewPost `json:"replies"`
}
)
func (record *Record) CreatedAtPrint() (string, error) {
t, err := record.CreatedAtTime()
if err != nil { return "", err }
return t.Format("2 Jan 2006, 15:04"), nil
}
func (record *Record) CreatedAtTime() (time.Time, error) {
return time.Parse("2006-01-02T15:04:05Z", record.CreatedAt)
}
func (post *Post) HasImage() bool {
return post.Embed != nil && len(post.Embed.Media.Images) > 0
}
func (post *Post) PostID() string {
return strings.TrimPrefix(
post.URI,
fmt.Sprintf("at://%s/app.bsky.feed.post/", post.Author.DID),
)
}
func (post *Post) BskyURL() string {
return fmt.Sprintf(
"https://bsky.app/profile/%s/post/%s",
post.Author.DID,
post.PostID(),
)
}

View file

@ -24,8 +24,3 @@ func (track Track) GetDescriptionHTML() template.HTML {
func (track Track) GetLyricsHTML() template.HTML {
return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>"))
}
// this function is stupid and i hate that i need it
func (track Track) Add(a int, b int) int {
return a + b
}

View file

@ -31,13 +31,3 @@ func Test_Track_LyricsHTML(t *testing.T) {
t.Errorf(`track lyrics incorrectly formatted (want "%s", got "%s")`, want, got)
}
}
func Test_Track_Add(t *testing.T) {
track := Track{}
want := 4
got := track.Add(2, 2)
if want != got {
t.Errorf(`somehow, we screwed up addition. (want %d, got %d)`, want, got)
}
}

BIN
public/img/aridoodle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="bluesky-dark" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M9.176,7.042c2.503,1.879 5.195,5.69 6.184,7.734c0.989,-2.044 3.681,-5.855 6.184,-7.734c1.806,-1.356 4.733,-2.405 4.733,0.933c-0,0.667 -0.383,5.601 -0.607,6.403c-0.779,2.785 -3.619,3.495 -6.145,3.065c4.415,0.752 5.539,3.241 3.113,5.73c-4.608,4.728 -6.622,-1.186 -7.138,-2.701c-0.095,-0.278 -0.139,-0.408 -0.14,-0.298c-0.001,-0.11 -0.045,0.02 -0.14,0.298c-0.516,1.515 -2.53,7.429 -7.138,2.701c-2.426,-2.489 -1.302,-4.978 3.113,-5.73c-2.526,0.43 -5.366,-0.28 -6.145,-3.065c-0.224,-0.802 -0.607,-5.736 -0.607,-6.403c0,-3.338 2.927,-2.289 4.733,-0.933Z" style="fill:#fff;fill-rule:nonzero;"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="bluesky-light" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M9.176,7.042c2.503,1.879 5.195,5.69 6.184,7.734c0.989,-2.044 3.681,-5.855 6.184,-7.734c1.806,-1.356 4.733,-2.405 4.733,0.933c-0,0.667 -0.383,5.601 -0.607,6.403c-0.779,2.785 -3.619,3.495 -6.145,3.065c4.415,0.752 5.539,3.241 3.113,5.73c-4.608,4.728 -6.622,-1.186 -7.138,-2.701c-0.095,-0.278 -0.139,-0.408 -0.14,-0.298c-0.001,-0.11 -0.045,0.02 -0.14,0.298c-0.516,1.515 -2.53,7.429 -7.138,2.701c-2.426,-2.489 -1.302,-4.978 3.113,-5.73c-2.526,0.43 -5.366,-0.28 -6.145,-3.065c-0.224,-0.802 -0.607,-5.736 -0.607,-6.403c0,-3.338 2.927,-2.289 4.733,-0.933Z" style="fill-rule:nonzero;"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="boost" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M20.14,5.466c1.708,-0.458 4.028,2.658 5.179,6.953c1.151,4.295 0.699,8.154 -1.008,8.611c-1.708,0.458 -4.029,-2.658 -5.179,-6.953c-1.151,-4.295 -0.699,-8.153 1.008,-8.611Zm0.954,3.56c-0.08,1.236 0.101,2.772 0.532,4.383c0.432,1.611 1.043,3.031 1.731,4.062c0.08,-1.237 -0.101,-2.772 -0.533,-4.383c-0.432,-1.611 -1.042,-3.031 -1.73,-4.062Zm-12.3,12.963l-0.907,0.056c-1.177,0.073 -2.242,-0.693 -2.548,-1.833l-0.468,-1.747c-0.305,-1.139 0.234,-2.336 1.29,-2.861l10.773,-8.542c0.173,-0.086 0.379,-0.064 0.53,0.056c0.151,0.12 0.218,0.316 0.174,0.503c-0.318,1.746 -0.19,4.164 0.496,6.724c0.685,2.559 1.784,4.717 2.928,6.074c0.132,0.138 0.171,0.34 0.101,0.517c-0.07,0.178 -0.236,0.299 -0.427,0.311c-1.7,0.108 -5.142,0.322 -8.287,0.516c-0.044,0.385 -0.09,0.771 -0.131,1.119c-0.091,0.771 -0.713,1.371 -1.486,1.435l-0.219,0.018c-0.647,0.054 -1.241,-0.355 -1.422,-0.978l-0.397,-1.368Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="boost" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M20.14,5.466c1.708,-0.458 4.028,2.658 5.179,6.953c1.151,4.295 0.699,8.154 -1.008,8.611c-1.708,0.458 -4.029,-2.658 -5.179,-6.953c-1.151,-4.295 -0.699,-8.153 1.008,-8.611Zm0.954,3.56c-0.08,1.236 0.101,2.772 0.532,4.383c0.432,1.611 1.043,3.031 1.731,4.062c0.08,-1.237 -0.101,-2.772 -0.533,-4.383c-0.432,-1.611 -1.042,-3.031 -1.73,-4.062Zm-12.3,12.963l-0.907,0.056c-1.177,0.073 -2.242,-0.693 -2.548,-1.833l-0.468,-1.747c-0.305,-1.139 0.234,-2.336 1.29,-2.861l10.773,-8.542c0.173,-0.086 0.379,-0.064 0.53,0.056c0.151,0.12 0.218,0.316 0.174,0.503c-0.318,1.746 -0.19,4.164 0.496,6.724c0.685,2.559 1.784,4.717 2.928,6.074c0.132,0.138 0.171,0.34 0.101,0.517c-0.07,0.178 -0.236,0.299 -0.427,0.311c-1.7,0.108 -5.142,0.322 -8.287,0.516c-0.044,0.385 -0.09,0.771 -0.131,1.119c-0.091,0.771 -0.713,1.371 -1.486,1.435l-0.219,0.018c-0.647,0.054 -1.241,-0.355 -1.422,-0.978l-0.397,-1.368Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="comment" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M26.117,14.576c-0,4.724 -4.82,8.559 -10.757,8.559c-1.015,-0 -2.025,-0.114 -3,-0.34l-5.008,1.907l-0.007,-4.418c-1.765,-1.569 -2.742,-3.601 -2.742,-5.708c0,-4.723 4.82,-8.558 10.757,-8.558c5.937,-0 10.757,3.835 10.757,8.558Zm-15.557,-1.135c-0.795,-0 -1.44,0.645 -1.44,1.44c0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm9.6,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm-4.8,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="comment" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M26.117,14.576c-0,4.724 -4.82,8.559 -10.757,8.559c-1.015,-0 -2.025,-0.114 -3,-0.34l-5.008,1.907l-0.007,-4.418c-1.765,-1.569 -2.742,-3.601 -2.742,-5.708c0,-4.723 4.82,-8.558 10.757,-8.558c5.937,-0 10.757,3.835 10.757,8.558Zm-15.557,-1.135c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm9.6,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm-4.8,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="copy-link" serif:id="copy link" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M10.578,14.996c-0.133,-1.199 0.107,-2.433 0.717,-3.512c0.254,-0.448 0.571,-0.869 0.953,-1.251l3.502,-3.503c2.274,-2.273 5.966,-2.273 8.24,0c2.273,2.274 2.273,5.966 -0,8.24l-1.962,1.962c-0.145,0.144 -0.364,0.186 -0.552,0.105c-0.188,-0.081 -0.307,-0.269 -0.302,-0.473c0.023,-0.778 -0.087,-1.559 -0.329,-2.307c-0.058,-0.179 -0.011,-0.375 0.122,-0.507l0.901,-0.902c1.103,-1.103 1.103,-2.893 0,-3.996c-1.103,-1.103 -2.893,-1.103 -3.996,-0l-3.503,3.502c-0.306,0.306 -0.527,0.665 -0.663,1.048c-0.355,0.996 -0.134,2.152 0.663,2.949c0.283,0.283 0.611,0.493 0.962,0.631c0.157,0.062 0.272,0.198 0.306,0.364c0.035,0.165 -0.016,0.336 -0.136,0.455l-1.491,1.491c-0.161,0.161 -0.411,0.193 -0.608,0.077c-0.412,-0.244 -0.8,-0.543 -1.154,-0.897c-0.973,-0.973 -1.53,-2.206 -1.67,-3.476Zm8.847,4.24c-0.254,0.448 -0.571,0.869 -0.953,1.251l-3.502,3.503c-2.274,2.273 -5.966,2.273 -8.24,-0c-2.273,-2.274 -2.273,-5.966 0,-8.24l1.962,-1.962c0.145,-0.144 0.364,-0.186 0.552,-0.105c0.188,0.081 0.307,0.269 0.302,0.473c-0.023,0.778 0.087,1.559 0.329,2.307c0.058,0.179 0.011,0.375 -0.122,0.507l-0.901,0.902c-1.103,1.103 -1.103,2.893 -0,3.996c1.103,1.103 2.893,1.103 3.996,0l3.503,-3.502c0.306,-0.306 0.527,-0.665 0.663,-1.048c0.355,-0.996 0.134,-2.152 -0.663,-2.949c-0.283,-0.283 -0.611,-0.493 -0.962,-0.631c-0.157,-0.062 -0.272,-0.198 -0.306,-0.364c-0.035,-0.165 0.016,-0.336 0.136,-0.455l1.491,-1.491c0.161,-0.161 0.411,-0.193 0.608,-0.077c0.412,0.244 0.8,0.543 1.154,0.897c0.973,0.973 1.53,2.205 1.67,3.476c0.133,1.199 -0.107,2.433 -0.717,3.512Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="copy-link" serif:id="copy link" x="0" y="-0" width="30.72" height="30.72" style="fill:none;"/><g id="copy-link1" serif:id="copy link"><path d="M10.578,14.996c-0.133,-1.199 0.107,-2.433 0.717,-3.512c0.254,-0.448 0.571,-0.869 0.953,-1.251l3.502,-3.503c2.274,-2.273 5.966,-2.273 8.24,0c2.273,2.274 2.273,5.966 -0,8.24l-1.962,1.962c-0.145,0.144 -0.364,0.186 -0.552,0.105c-0.188,-0.081 -0.307,-0.269 -0.302,-0.473c0.023,-0.778 -0.087,-1.559 -0.329,-2.307c-0.058,-0.179 -0.011,-0.375 0.122,-0.507l0.901,-0.902c1.103,-1.103 1.103,-2.893 0,-3.996c-1.103,-1.103 -2.893,-1.103 -3.996,-0l-3.503,3.502c-0.306,0.306 -0.527,0.665 -0.663,1.048c-0.355,0.996 -0.134,2.152 0.663,2.949c0.283,0.283 0.611,0.493 0.962,0.631c0.157,0.062 0.272,0.198 0.306,0.364c0.035,0.165 -0.016,0.336 -0.136,0.455l-1.491,1.491c-0.161,0.161 -0.411,0.193 -0.608,0.077c-0.412,-0.244 -0.8,-0.543 -1.154,-0.897c-0.973,-0.973 -1.53,-2.206 -1.67,-3.476Zm8.847,4.24c-0.254,0.448 -0.571,0.869 -0.953,1.251l-3.502,3.503c-2.274,2.273 -5.966,2.273 -8.24,-0c-2.273,-2.274 -2.273,-5.966 0,-8.24l1.962,-1.962c0.145,-0.144 0.364,-0.186 0.552,-0.105c0.188,0.081 0.307,0.269 0.302,0.473c-0.023,0.778 0.087,1.559 0.329,2.307c0.058,0.179 0.011,0.375 -0.122,0.507l-0.901,0.902c-1.103,1.103 -1.103,2.893 -0,3.996c1.103,1.103 2.893,1.103 3.996,0l3.503,-3.502c0.306,-0.306 0.527,-0.665 0.663,-1.048c0.355,-0.996 0.134,-2.152 -0.663,-2.949c-0.283,-0.283 -0.611,-0.493 -0.962,-0.631c-0.157,-0.062 -0.272,-0.198 -0.306,-0.364c-0.035,-0.165 0.016,-0.336 0.136,-0.455l1.491,-1.491c0.161,-0.161 0.411,-0.193 0.608,-0.077c0.412,0.244 0.8,0.543 1.154,0.897c0.973,0.973 1.53,2.205 1.67,3.476c0.133,1.199 -0.107,2.433 -0.717,3.512Z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="like" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M15.36,9.159c2.176,-4.134 6.527,-4.134 8.703,-2.067c2.176,2.067 2.176,6.201 0,10.335c-1.523,3.1 -5.439,6.201 -8.703,8.268c-3.264,-2.067 -7.18,-5.168 -8.703,-8.268c-2.176,-4.134 -2.176,-8.268 -0,-10.335c2.176,-2.067 6.527,-2.067 8.703,2.067Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 794 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="like" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M15.36,9.159c2.176,-4.134 6.527,-4.134 8.703,-2.067c2.176,2.067 2.176,6.201 0,10.335c-1.523,3.1 -5.439,6.201 -8.703,8.268c-3.264,-2.067 -7.18,-5.168 -8.703,-8.268c-2.176,-4.134 -2.176,-8.268 -0,-10.335c2.176,-2.067 6.527,-2.067 8.703,2.067Z"/></svg>

After

Width:  |  Height:  |  Size: 775 B

15
public/script/blog.js Normal file
View file

@ -0,0 +1,15 @@
import { hijackClickEvent } from "./main.js";
document.querySelectorAll('article.blog-post').forEach(element => {
const link = element.querySelector('.blog-title a');
hijackClickEvent(element, link);
});
document.querySelectorAll('article.blog-post').forEach(element => {
const link = element.querySelector('.blog-title a');
hijackClickEvent(element, link);
});
document.getElementById('load-more').addEventListener('click', event => {
event.preventDefault();
alert('ok');
});

20
public/script/blogpost.js Normal file
View file

@ -0,0 +1,20 @@
import { hijackClickEvent } from "./main.js";
document.querySelectorAll('.comment-hover').forEach((/** @type {HTMLDivElement} */ comment) => {
/** @type {HTMLLinkElement} */
const commentDate = comment.querySelector('.comment-date');
hijackClickEvent(comment, commentDate);
});
/*
document.getElementById('blog-copy-link').addEventListener('click', event => {
event.preventDefault();
if (navigator.clipboard === undefined) {
console.error("clipboard is not supported by this browser!");
return;
}
navigator.clipboard.writeText(location.protocol + "//" + location.host + location.pathname);
});
*/
hljs.highlightAll();

70
public/style/blog.css Normal file
View file

@ -0,0 +1,70 @@
article.blog-post {
margin-bottom: 1rem;
padding: 1.5rem;
border: 1px solid #8882;
border-radius: 4px;
background-color: #ffffff08;
transition: background-color .1s;
text-decoration: none;
cursor: pointer;
}
.blog-post h2:hover,
.blog-post p:hover {
background: none;
}
.blog-title {
margin: 0;
}
.blog-title a {
display: inherit;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.blog-meta {
margin: 0;
}
.blog-author {
margin: 0;
font-size: .8em;
}
.blog-author img {
width: 1.3em;
height: 1.3em;
display: inline-block;
transform: translate(0, 4px);
border-radius: 4px;
}
.blog-date {
margin: 0;
font-size: .8em;
opacity: .75;
}
.blog-description {
margin: .5em 0 0 0;
display: -webkit-box;
font-size: .8em;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
#load-more {
margin: 0 auto;
padding: .5em 2em;
display: block;
font-family: inherit;
font-size: inherit;
}
@media screen and (max-width: 1500px) {
#blog-sidebar {
display: none;
}
}

349
public/style/blogpost.css Normal file
View file

@ -0,0 +1,349 @@
:root {
--like: rgb(223, 104, 104);
--boost: rgb(162, 223, 73);
--bluesky: rgb(16, 131, 254);
--mastodon: rgb(86, 58, 204);
}
main {
width: min(calc(100% - 4rem), 800px);
margin: 0 auto 1rem auto;
}
#blog-sidebar {
position: fixed;
width: 3em;
padding: 3em;
transform: translate(-9em, -1em);
overflow: clip;
opacity: .5;
transition: opacity .2s;
}
#blog-sidebar:hover {
opacity: 1;
}
#blog-sidebar ul {
margin: 0;
padding: .3em;
list-style: none;
display: flex;
flex-direction: column;
gap: .3em;
border-radius: 4px;
border: 1px solid var(--on-background);
box-shadow: 4px 4px 4px #0001;
}
#blog-sidebar a {
width: 35px;
height: 35px;
display: block;
padding: .2em;
border-radius: 2px;
text-decoration: none;
}
#blog-sidebar a:hover {
background: #0001;
}
#blog-sidebar a:active {
background: #0002;
}
#blog-sidebar a img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
#blog-sidebar span {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5em;
}
#blog-sidebar hr {
margin: 0;
}
#blog p:hover,
.comment p:hover {
background: inherit;
}
#blog {
font-size: 22px;
}
#blog h1 {
margin-bottom: 0;
font-size: 1.8em;
}
#blog h2::before { content: "## " }
#blog h3::before { content: "### " }
.blog-author {
margin: .2em 0;
}
#blog .blog-author img {
width: 1.3em;
height: 1.3em;
display: inline-block;
transform: translate(0, 6px);
border-radius: 4px;
}
.blog-date {
margin: .5em 0;
font-size: .7em;
}
.blog-modified-date {
font-style: italic;
opacity: .75;
}
#blog {
font-family: 'Lora', serif;
}
#blog header {
position: relative;
width: auto;
font-family: 'Monaspace Argon', monospace;
border: none;
background: none;
z-index: 0;
}
#blog p {
line-height: 1.25em;
}
#blog p.no-content {
font-style: italic;
opacity: .66;
}
#blog sub {
opacity: .75;
}
#blog pre {
max-height: 15em;
padding: .5em;
font-size: .9em;
border: 1px solid #8884;
border-radius: 2px;
overflow: scroll;
background: var(--background-alt);
}
#blog p code {
padding: .2em .3em;
font-size: .9em;
border: 1px solid #8884;
border-radius: 2px;
background: var(--background-alt);
}
#blog blockquote {
margin: 1em 0;
padding: 0 0 0 1em;
border-left: .2em solid #8888;
}
#blog img {
max-height: 50%;
max-width: 100%;
display: block;
}
#blog figure {
margin: 1em 0;
padding: 0 1em;
}
#blog figure.quote {
border-left: 4px solid color-mix(in srgb, var(--on-background), transparent 75%);
color: color-mix(in srgb, var(--on-background), transparent 25%);
}
#blog i.end-mark {
width: 1.2em;
height: 1.2em;
margin-top: -.2em;
display: inline-block;
transform: translateY(.2em);
background: url("/img/aridoodle.png");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* COMMENTS */
#interactions {
margin: 1em 0;
display: flex;
flex-direction: row;
gap: .5em;
flex-wrap: wrap;
align-items: center;
}
.btn {
display: inline-block;
padding: .4em .6em;
border: 1px solid var(--on-background);
border-radius: 2px;
color: inherit;
text-decoration: none;
font-weight: 600;
}
#interactions .button {
min-width: fit-content;
padding: 0 .75em 0 .5em;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: .5em;
font-family: monospace;
font-size: inherit;
text-align: center;
line-height: 2em;
text-wrap: nowrap;
color: inherit;
background: none;
border: 1px solid var(--on-background);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
#interactions .button:hover {
background: #0001;
}
#interactions .button:active {
background: #0002;
}
#interactions img {
width: 1.5em;
height: 1.5em;
display: inline-block;
}
.comment-callout {
margin: 0 0 0 .5em;
}
.comment-callout:hover {
background: none;
}
.bluesky {
color: var(--bluesky);
}
.mastodon {
color: var(--mastodon);
}
.comment {
font-family: 'Inter', 'Arial', sans-serif;
font-size: 1em;
}
.comment .comment-hover {
padding: 1em;
transition: background-color .1s;
cursor: pointer;
}
.comment .comment-hover:hover {
background-color: #8881;
}
.comment .comment-header a {
display: flex;
gap: .5em;
font-weight: 600;
color: var(--primary);
text-decoration: none;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
}
.comment .comment-header a .display-name {
overflow: inherit;
text-overflow: inherit;
}
.comment .comment-header a .handle {
opacity: .5;
font-family: monospace;
font-size: .9em;
overflow: inherit;
text-overflow: inherit;
}
.comment .comment-header img.avatar {
width: 1.5em;
height: 1.5em;
border-radius: 4px;
}
.comment .comment-body {
color: inherit;
text-decoration: none;
}
.comment p.comment-text {
margin: .5em 0;
white-space: break-spaces;
}
.comment .comment-footer {
margin: 0;
font-size: .8em;
}
.comment .comment-footer .comment-footer-static {
opacity: .5;
}
.comment .comment-replies {
margin-left: 1em;
border-left: 2px solid #8884;
}
.comment .comment-date {
transition: opacity .2s;
}
@media screen and (prefers-color-scheme: dark) {
#blog-sidebar a:hover {
background: #fff2;
}
#blog-sidebar a:active {
background: #fff4;
}
.comment-date {
opacity: .5;
}
}

View file

@ -1,5 +1,6 @@
:root {
--background: #080808;
--background-alt: #040404;
--on-background: #f0f0f0;
--primary: #b7fd49;
@ -12,6 +13,7 @@
@media (prefers-color-scheme: light) {
:root {
--background: #ffffff;
--background-alt: #f0f0f0;
--on-background: #101010;
--primary: #6d9e23;

View file

@ -153,7 +153,7 @@ header ul li a:hover {
flex-direction: column;
gap: 1rem;
border-bottom: 1px solid #888;
background: var(--background);
background-color: var(--background);
display: none;
}

View file

@ -1,6 +1,6 @@
main {
width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem);
min-height: calc(100vh - 10.3em);
margin: 0 auto 2rem auto;
padding-top: 4rem;
}

View file

@ -16,7 +16,7 @@
body {
margin: 0;
padding: 0;
background: var(--background);
background-color: var(--background);
color: var(--on-background);
font-family: "Monaspace Argon", monospace;
font-size: 18px;
@ -38,7 +38,7 @@ a:hover {
text-decoration: underline;
}
a.link-button {
.link-button {
padding: .3em .5em;
border: 1px solid var(--links);
color: var(--links);
@ -51,7 +51,7 @@ a.link-button {
opacity: 0;
}
a.link-button:hover {
.link-button:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
@ -141,8 +141,23 @@ a#backtotop:hover {
}
}
.light-only {
display: none;
}
.dark-only {
display: inherit;
}
@media (prefers-color-scheme: light) {
.light-only {
display: inherit;
}
.dark-only {
display: none;
}
a.link-button:hover {
box-shadow: none;
}
@ -150,7 +165,7 @@ a#backtotop:hover {
@keyframes list-item-fadein {
from {
opacity: 1;
background: var(--links);
background-color: var(--links);
}
to {
@ -161,6 +176,14 @@ a#backtotop:hover {
}
@media (prefers-color-scheme: dark) {
.light-only {
display: none;
}
.dark-only {
display: inherit;
}
body.crt {
text-shadow: 0 0 3em;
}

View file

@ -30,7 +30,7 @@ header {
background-size: cover;
background-position: center;
filter: blur(25px) saturate(25%) brightness(0.5);
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);;
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);
animation: background-init .5s forwards,background-loop 30s ease-in-out infinite
}

6
public/vendor/highlight/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
es
package.json
DIGESTS.md
highlight.js
languages/*.js
!languages/*.min.js

29
public/vendor/highlight/LICENSE vendored Normal file
View file

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2006, Ivan Sagalaev.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

45
public/vendor/highlight/README.md vendored Normal file
View file

@ -0,0 +1,45 @@
# Highlight.js CDN Assets
[![install size](https://packagephobia.now.sh/badge?p=highlight.js)](https://packagephobia.now.sh/result?p=highlight.js)
**This package contains only the CDN build assets of highlight.js.**
This may be what you want if you'd like to install the pre-built distributable highlight.js client-side assets via NPM. If you're wanting to use highlight.js mainly on the server-side you likely want the [highlight.js][1] package instead.
To access these files via CDN:<br>
https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/
**If you just want a single .js file with the common languages built-in:
<https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/highlight.min.js>**
---
## Highlight.js
Highlight.js is a syntax highlighter written in JavaScript. It works in
the browser as well as on the server. It works with pretty much any
markup, doesnt depend on any framework, and has automatic language
detection.
If you'd like to read the full README:<br>
<https://github.com/highlightjs/highlight.js/blob/main/README.md>
## License
Highlight.js is released under the BSD License. See [LICENSE][7] file
for details.
## Links
The official site for the library is at <https://highlightjs.org/>.
The Github project may be found at: <https://github.com/highlightjs/highlight.js>
Further in-depth documentation for the API and other topics is at
<http://highlightjs.readthedocs.io/>.
A list of the Core Team and contributors can be found in the [CONTRIBUTORS.md][8] file.
[1]: https://www.npmjs.com/package/highlight.js
[7]: https://github.com/highlightjs/highlight.js/blob/main/LICENSE
[8]: https://github.com/highlightjs/highlight.js/blob/main/CONTRIBUTORS.md

912
public/vendor/highlight/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,21 @@
/*! `bash` grammar compiled for Highlight.js 11.11.1 */
(()=>{var e=(()=>{"use strict";return e=>{const s=e.regex,t={},n={begin:/\$\{/,
end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]};Object.assign(t,{
className:"variable",variants:[{
begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},n]});const a={
className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]
},i=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),c={
begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,
end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/,
contains:[e.BACKSLASH_ESCAPE,t,a]};a.contains.push(o);const r={begin:/\$?\(\(/,
end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t]
},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10
}),m={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,
contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{
name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/,
keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"],
literal:["true","false"],
built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"]
},contains:[l,e.SHEBANG(),m,r,i,c,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{
className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}})()
;hljs.registerLanguage("bash",e)})();

View file

@ -0,0 +1,41 @@
/*! `c` grammar compiled for Highlight.js 11.11.1 */
(()=>{var e=(()=>{"use strict";return e=>{const t=e.regex,n=e.COMMENT("//","$",{
contains:[{begin:/\\\n/}]
}),a="decltype\\(auto\\)",s="[a-zA-Z_]\\w*::",r="("+a+"|"+t.optional(s)+"[a-zA-Z_]\\w*"+t.optional("<[^<>]+>")+")",i={
className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{
match:/\batomic_[a-z]{3,6}\b/}]},l={className:"string",variants:[{
begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{
begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
end:"'",illegal:"."},e.END_SAME_AS_BEGIN({
begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
className:"number",variants:[{match:/\b(0b[01']+)/},{
match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/
},{
match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/
},{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0
},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include"
},contains:[{begin:/\\\n/,relevance:0},e.inherit(l,{className:"string"}),{
className:"string",begin:/<.*?>/},n,e.C_BLOCK_COMMENT_MODE]},d={
className:"title",begin:t.optional(s)+e.IDENT_RE,relevance:0
},_=t.optional(s)+e.IDENT_RE+"\\s*\\(",u={
keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"],
type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"],
literal:"true false NULL",
built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr"
},m=[c,i,n,e.C_BLOCK_COMMENT_MODE,o,l],g={variants:[{begin:/=/,end:/;/},{
begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],
keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u,
contains:m.concat(["self"]),relevance:0}]),relevance:0},p={
begin:"("+r+"[\\*&\\s]+)+"+_,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,
keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{
begin:_,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})],
relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/,
keywords:u,relevance:0,contains:[n,e.C_BLOCK_COMMENT_MODE,l,o,i,{begin:/\(/,
end:/\)/,keywords:u,relevance:0,contains:["self",n,e.C_BLOCK_COMMENT_MODE,l,o,i]
}]},i,n,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u,
disableAutodetect:!0,illegal:"</",contains:[].concat(g,p,m,[c,{
begin:e.IDENT_RE+"::",keywords:u},{className:"class",
beginKeywords:"enum class struct union",end:/[{;:<>=]/,contains:[{
beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c,
strings:l,keywords:u}}}})();hljs.registerLanguage("c",e)})();

View file

@ -0,0 +1,47 @@
/*! `cpp` grammar compiled for Highlight.js 11.11.1 */
(()=>{var e=(()=>{"use strict";return e=>{const t=e.regex,a=e.COMMENT("//","$",{
contains:[{begin:/\\\n/}]
}),n="decltype\\(auto\\)",r="[a-zA-Z_]\\w*::",i="(?!struct)("+n+"|"+t.optional(r)+"[a-zA-Z_]\\w*"+t.optional("<[^<>]+>")+")",s={
className:"type",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",variants:[{
begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{
begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
end:"'",illegal:"."},e.END_SAME_AS_BEGIN({
begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
className:"number",variants:[{
begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)"
},{
begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)"
}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
},contains:[{begin:/\\\n/,relevance:0},e.inherit(c,{className:"string"}),{
className:"string",begin:/<.*?>/},a,e.C_BLOCK_COMMENT_MODE]},u={
className:"title",begin:t.optional(r)+e.IDENT_RE,relevance:0
},d=t.optional(r)+e.IDENT_RE+"\\s*\\(",p={
type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"],
keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"],
literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"],
_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","flat_map","flat_set","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"]
},_={className:"function.dispatch",relevance:0,keywords:{
_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"]
},
begin:t.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,t.lookahead(/(<[^<>]+>|)\s*\(/))
},m=[_,l,s,a,e.C_BLOCK_COMMENT_MODE,o,c],f={variants:[{begin:/=/,end:/;/},{
begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],
keywords:p,contains:m.concat([{begin:/\(/,end:/\)/,keywords:p,
contains:m.concat(["self"]),relevance:0}]),relevance:0},g={className:"function",
begin:"("+i+"[\\*&\\s]+)+"+d,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,
keywords:p,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:n,keywords:p,relevance:0},{
begin:d,returnBegin:!0,contains:[u],relevance:0},{begin:/::/,relevance:0},{
begin:/:/,endsWithParent:!0,contains:[c,o]},{relevance:0,match:/,/},{
className:"params",begin:/\(/,end:/\)/,keywords:p,relevance:0,
contains:[a,e.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,end:/\)/,keywords:p,
relevance:0,contains:["self",a,e.C_BLOCK_COMMENT_MODE,c,o,s]}]
},s,a,e.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"</",
classNameAliases:{"function.dispatch":"built_in"},
contains:[].concat(f,g,_,m,[l,{
begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array|tuple|optional|variant|function|flat_map|flat_set)\\s*<(?!<)",
end:">",keywords:p,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:p},{
match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/],
className:{1:"keyword",3:"title.class"}}])}}})();hljs.registerLanguage("cpp",e)
})();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,20 @@
/*! `go` grammar compiled for Highlight.js 11.11.1 */
(()=>{var e=(()=>{"use strict";return e=>{const a={
keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"],
type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"],
literal:["true","false","iota","nil"],
built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"]
};return{name:"Go",aliases:["golang"],keywords:a,illegal:"</",
contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{
className:"number",variants:[{
match:/-?\b0[xX]\.[a-fA-F0-9](_?[a-fA-F0-9])*[pP][+-]?\d(_?\d)*i?/,relevance:0
},{
match:/-?\b0[xX](_?[a-fA-F0-9])+((\.([a-fA-F0-9](_?[a-fA-F0-9])*)?)?[pP][+-]?\d(_?\d)*)?i?/,
relevance:0},{match:/-?\b0[oO](_?[0-7])*i?/,relevance:0},{
match:/-?\.\d(_?\d)*([eE][+-]?\d(_?\d)*)?i?/,relevance:0},{
match:/-?\b\d(_?\d)*(\.(\d(_?\d)*)?)?([eE][+-]?\d(_?\d)*)?i?/,relevance:0}]},{
begin:/:=/},{className:"function",beginKeywords:"func",end:"\\s*(\\{|$)",
excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,
endsParent:!0,keywords:a,illegal:/["']/}]}]}}})();hljs.registerLanguage("go",e)
})();

Some files were not shown because too many files have changed in this diff Show more