Compare commits
37 commits
feature/to
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
0fc6c9f86d | ||
|
3cb8d2940a | ||
|
edb4d7df3a | ||
|
23dbbf26e3 | ||
|
d9b71381b0 | ||
|
1397274967 | ||
|
01c285de1b | ||
|
b7938b02e8 | ||
|
aa144b719a | ||
|
e80a6753a5 | ||
|
8ccf6f242b | ||
|
e07c68a564 | ||
|
e6c5ecc469 | ||
|
4b36603b89 | ||
|
64780611de | ||
|
b7fce821b4 | ||
|
70825ae875 | ||
|
1efe52a8cb | ||
|
2e93c3c5e5 | ||
|
2fc855450e | ||
|
b91b6e7ce0 | ||
|
d2ac66a81a | ||
|
3450d879ac | ||
|
1edc051ae2 | ||
|
ad39e68cd6 | ||
|
090de0554b | ||
|
9a27dbdc37 | ||
|
e004491b55 | ||
|
50cbce92fc | ||
|
e457e979ff | ||
|
45f33b8b46 | ||
|
cee99a6932 | ||
|
2ee874b2ca | ||
|
c6afc274c2 | ||
|
0052c470f9 | ||
|
5531ef6bab | ||
|
384579ee5e |
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -7,3 +7,4 @@ uploads/
|
|||
docker-compose*.yml
|
||||
!docker-compose.example.yml
|
||||
config*.toml
|
||||
arimelody-web
|
||||
|
|
22
LICENSE.md
Normal file
22
LICENSE.md
Normal file
|
@ -0,0 +1,22 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025-present ari melody
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -42,6 +42,7 @@ need to be up for this, making this ideal for some offline maintenance.
|
|||
- `listTOTP <username>`: Lists an account's TOTP methods.
|
||||
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
|
||||
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
|
||||
- `cleanTOTP`: Cleans up unconfirmed (dangling) TOTP methods.
|
||||
- `createInvite`: Creates an invite code to register new accounts.
|
||||
- `purgeInvites`: Deletes all available invite codes.
|
||||
- `listAccounts`: Lists all active accounts.
|
||||
|
|
|
@ -1,43 +1,71 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type TemplateData struct {
|
||||
Account *model.Account
|
||||
Message string
|
||||
Token string
|
||||
func accountHandler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/totp-setup", totpSetupHandler(app))
|
||||
mux.Handle("/totp-confirm", totpConfirmHandler(app))
|
||||
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
|
||||
|
||||
mux.Handle("/password", changePasswordHandler(app))
|
||||
mux.Handle("/delete", deleteAccountHandler(app))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func AccountHandler(db *sqlx.DB) http.Handler {
|
||||
func accountIndexHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account := r.Context().Value("account").(*model.Account)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(db, account.ID)
|
||||
dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
type AccountResponse struct {
|
||||
Account *model.Account
|
||||
TOTPs []model.TOTP
|
||||
type (
|
||||
TOTP struct {
|
||||
model.TOTP
|
||||
CreatedAtString string
|
||||
}
|
||||
|
||||
accountResponse struct {
|
||||
Session *model.Session
|
||||
TOTPs []TOTP
|
||||
}
|
||||
)
|
||||
|
||||
totps := []TOTP{}
|
||||
for _, totp := range dbTOTPs {
|
||||
totps = append(totps, TOTP{
|
||||
TOTP: totp,
|
||||
CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
err = pages["account"].Execute(w, AccountResponse{
|
||||
Account: account,
|
||||
sessionMessage := session.Message
|
||||
sessionError := session.Error
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
session.Message = sessionMessage
|
||||
session.Error = sessionError
|
||||
|
||||
err = accountTemplate.Execute(w, accountResponse{
|
||||
Session: session,
|
||||
TOTPs: totps,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -47,299 +75,296 @@ func AccountHandler(db *sqlx.DB) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func LoginHandler(db *sqlx.DB) http.Handler {
|
||||
func changePasswordHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
account, err := controller.GetAccountByRequest(db, r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{})
|
||||
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.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Account *model.Account
|
||||
Token string
|
||||
Message string
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
render := func(data LoginResponse) {
|
||||
err := pages["login"].Execute(w, data)
|
||||
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
|
||||
}
|
||||
newPassword := r.Form.Get("new-password")
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 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.")
|
||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
session.Account.Password = string(hashedPassword)
|
||||
err = controller.UpdateAccount(app.DB, session.Account)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
||||
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r);
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
render(LoginResponse{ Message: "Malformed request." })
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
credentials := LoginRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Password: r.Form.Get("password"),
|
||||
TOTP: r.Form.Get("totp"),
|
||||
if !r.Form.Has("password") {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(db, credentials.Username)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
// check password
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteAccount(app.DB, session.Account.ID)
|
||||
if err != nil {
|
||||
render(LoginResponse{ Message: "Invalid username or password" })
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
render(LoginResponse{ Message: "Invalid username or password" })
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
if err != nil {
|
||||
render(LoginResponse{ Message: "Invalid username or password" })
|
||||
return
|
||||
}
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(db, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
||||
render(LoginResponse{ Message: "Something went wrong. Please try again." })
|
||||
return
|
||||
}
|
||||
if len(totps) > 0 {
|
||||
success := false
|
||||
for _, totp := range totps {
|
||||
check := controller.GenerateTOTP(totp.Secret, 0)
|
||||
if check == credentials.TOTP {
|
||||
success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
render(LoginResponse{ Message: "Invalid TOTP" })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// TODO: user should be prompted to add 2FA method
|
||||
}
|
||||
|
||||
// login success!
|
||||
token, err := controller.CreateToken(db, account.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
||||
render(LoginResponse{ Message: "Something went wrong. Please try again." })
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
render(LoginResponse{ Account: account, Token: token.Token })
|
||||
controller.SetSessionAccount(app.DB, session, nil)
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
controller.SetSessionMessage(app.DB, session, "Account deleted successfully.")
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func LogoutHandler(db *sqlx.DB) http.Handler {
|
||||
type totpConfirmData struct {
|
||||
Session *model.Session
|
||||
TOTP *model.TOTP
|
||||
NameEscaped string
|
||||
QRBase64Image string
|
||||
}
|
||||
|
||||
func totpSetupHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
type totpSetupData struct {
|
||||
Session *model.Session
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := totpSetupTemplate.Execute(w, totpSetupData{ 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
name := r.FormValue("totp-name")
|
||||
if len(name) == 0 {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
||||
totp := model.TOTP {
|
||||
AccountID: session.Account.ID,
|
||||
Name: name,
|
||||
Secret: string(secret),
|
||||
}
|
||||
err = controller.CreateTOTP(app.DB, &totp)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
err := totpSetupTemplate.Execute(w, totpConfirmData{ 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)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
qrBase64Image, err := controller.GenerateQRCode(
|
||||
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
||||
}
|
||||
|
||||
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
||||
Session: session,
|
||||
TOTP: &totp,
|
||||
NameEscaped: url.PathEscape(totp.Name),
|
||||
QRBase64Image: qrBase64Image,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("totp-name")
|
||||
if len(name) == 0 {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := r.FormValue("totp")
|
||||
if len(code) != controller.TOTP_CODE_LENGTH {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
||||
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)
|
||||
return
|
||||
}
|
||||
if totp == nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
qrBase64Image, err := controller.GenerateQRCode(
|
||||
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
||||
}
|
||||
|
||||
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
|
||||
if code != confirmCode {
|
||||
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
||||
if code != confirmCodeOffset {
|
||||
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
|
||||
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
||||
Session: session,
|
||||
TOTP: totp,
|
||||
NameEscaped: url.PathEscape(totp.Name),
|
||||
QRBase64Image: qrBase64Image,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
|
||||
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
|
||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func totpDeleteHandler(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
|
||||
}
|
||||
|
||||
tokenStr := controller.GetTokenFromRequest(db, r)
|
||||
|
||||
if len(tokenStr) > 0 {
|
||||
err := controller.DeleteToken(db, tokenStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(r.URL.Path) < 2 {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.URL.Path[1:]
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = ""
|
||||
cookie.Expires = time.Now()
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
func createAccountHandler(db *sqlx.DB) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checkAccount, err := controller.GetAccountByRequest(db, r)
|
||||
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
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)
|
||||
return
|
||||
}
|
||||
if checkAccount != nil {
|
||||
// user is already logged in
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
type CreateAccountResponse struct {
|
||||
Account *model.Account
|
||||
Message string
|
||||
}
|
||||
|
||||
render := func(data CreateAccountResponse) {
|
||||
err := pages["create-account"].Execute(w, data)
|
||||
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(CreateAccountResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
if totp == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseForm()
|
||||
err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name)
|
||||
if err != nil {
|
||||
render(CreateAccountResponse{
|
||||
Message: "Malformed data.",
|
||||
})
|
||||
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)
|
||||
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"),
|
||||
}
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
|
||||
|
||||
// make sure code exists in DB
|
||||
invite, err := controller.GetInvite(db, credentials.Invite)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if invite == nil || time.Now().After(invite.ExpiresAt) {
|
||||
if invite != nil {
|
||||
err := controller.DeleteInvite(db, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
}
|
||||
render(CreateAccountResponse{
|
||||
Message: "Invalid invite code.",
|
||||
})
|
||||
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)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: string(hashedPassword),
|
||||
Email: credentials.Email,
|
||||
AvatarURL: "/img/default-avatar.png",
|
||||
}
|
||||
err = controller.CreateAccount(db, &account)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
render(CreateAccountResponse{
|
||||
Message: "An account with that username already exists.",
|
||||
})
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||
render(CreateAccountResponse{
|
||||
Message: "Something went wrong. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteInvite(db, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
|
||||
// registration success!
|
||||
token, err := controller.CreateToken(db, account.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
|
||||
// gracefully redirect user to login page
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
cookie.HttpOnly = true
|
||||
cookie.Path = "/"
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
err = pages["login"].Execute(w, TemplateData{
|
||||
Account: &account,
|
||||
Token: token.Token,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5,16 +5,15 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/controller"
|
||||
)
|
||||
|
||||
func serveArtist() http.Handler {
|
||||
func serveArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slices := strings.Split(r.URL.Path[1:], "/")
|
||||
id := slices[0]
|
||||
artist, err := controller.GetArtist(global.DB, id)
|
||||
artist, err := controller.GetArtist(app.DB, id)
|
||||
if err != nil {
|
||||
if artist == nil {
|
||||
http.NotFound(w, r)
|
||||
|
@ -25,7 +24,7 @@ func serveArtist() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
credits, err := controller.GetArtistCredits(global.DB, artist.ID, true)
|
||||
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -33,15 +32,15 @@ func serveArtist() http.Handler {
|
|||
}
|
||||
|
||||
type ArtistResponse struct {
|
||||
Account *model.Account
|
||||
Session *model.Session
|
||||
Artist *model.Artist
|
||||
Credits []*model.Credit
|
||||
}
|
||||
|
||||
account := r.Context().Value("account").(*model.Account)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err = pages["artist"].Execute(w, ArtistResponse{
|
||||
Account: account,
|
||||
err = artistTemplate.Execute(w, ArtistResponse{
|
||||
Session: session,
|
||||
Artist: artist,
|
||||
Credits: credits,
|
||||
})
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<dialog id="addcredit">
|
||||
<header>
|
||||
<h2>Add artist credit</h2>
|
||||
<h2>Add Artist Credit</h2>
|
||||
</header>
|
||||
|
||||
<ul>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<dialog id="addtrack">
|
||||
<header>
|
||||
<h2>Add track</h2>
|
||||
<h2>Add Track</h2>
|
||||
</header>
|
||||
|
||||
<ul>
|
||||
|
|
|
@ -3,20 +3,20 @@
|
|||
<h2>Editing: Tracks</h2>
|
||||
<a id="add-track"
|
||||
class="button new"
|
||||
href="/admin/release/{{.ID}}/addtrack"
|
||||
hx-get="/admin/release/{{.ID}}/addtrack"
|
||||
href="/admin/release/{{.Release.ID}}/addtrack"
|
||||
hx-get="/admin/release/{{.Release.ID}}/addtrack"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Add</a>
|
||||
</header>
|
||||
|
||||
<form action="/api/v1/music/{{.ID}}/tracks">
|
||||
<form action="/api/v1/music/{{.Release.ID}}/tracks">
|
||||
<ul>
|
||||
{{range $i, $track := .Tracks}}
|
||||
{{range $i, $track := .Release.Tracks}}
|
||||
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
|
||||
<div>
|
||||
<p class="track-name">
|
||||
<span class="track-number">{{$track.Add $i 1}}</span>
|
||||
<span class="track-number">{{.Add $i 1}}</span>
|
||||
{{$track.Title}}
|
||||
</p>
|
||||
<a class="delete">Delete</a>
|
||||
|
|
459
admin/http.go
459
admin/http.go
|
@ -2,104 +2,444 @@ package admin
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func Handler(db *sqlx.DB) http.Handler {
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/login", LoginHandler(db))
|
||||
mux.Handle("/register", createAccountHandler(db))
|
||||
mux.Handle("/logout", RequireAccount(db, LogoutHandler(db)))
|
||||
mux.Handle("/account", RequireAccount(db, AccountHandler(db)))
|
||||
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
|
||||
}
|
||||
|
||||
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
|
||||
}))
|
||||
|
||||
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(http.StripPrefix("/account", accountHandler(app))))
|
||||
|
||||
mux.Handle("/logs", requireAccount(logsHandler(app)))
|
||||
|
||||
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
|
||||
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
|
||||
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
|
||||
|
||||
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
||||
mux.Handle("/release/", RequireAccount(db, http.StripPrefix("/release", serveRelease())))
|
||||
mux.Handle("/artist/", RequireAccount(db, http.StripPrefix("/artist", serveArtist())))
|
||||
mux.Handle("/track/", RequireAccount(db, http.StripPrefix("/track", serveTrack())))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
|
||||
|
||||
// response wrapper to make sure a session cookie exists
|
||||
return enforceSession(app, mux)
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccountByRequest(db, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
|
||||
}
|
||||
if account == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
releases, err := controller.GetAllReleases(db, false, 0, true)
|
||||
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
artists, err := controller.GetAllArtists(db)
|
||||
artists, err := controller.GetAllArtists(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tracks, err := controller.GetOrphanTracks(db)
|
||||
if err != nil {
|
||||
tracks, err := controller.GetOrphanTracks(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type IndexData struct {
|
||||
Account *model.Account
|
||||
Session *model.Session
|
||||
Releases []*model.Release
|
||||
Artists []*model.Artist
|
||||
Tracks []*model.Track
|
||||
}
|
||||
|
||||
err = pages["index"].Execute(w, IndexData{
|
||||
Account: account,
|
||||
err = indexTemplate.Execute(w, IndexData{
|
||||
Session: session,
|
||||
Releases: releases,
|
||||
Artists: artists,
|
||||
Tracks: tracks,
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}))
|
||||
|
||||
return mux
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
|
||||
func registerAccountHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account, err := controller.GetAccountByRequest(db, r)
|
||||
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 := 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.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
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)
|
||||
|
||||
type loginData struct {
|
||||
Session *model.Session
|
||||
}
|
||||
|
||||
render := func() {
|
||||
err := loginTemplate.Execute(w, loginData{ 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
|
||||
}
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
type loginTOTPData struct {
|
||||
Session *model.Session
|
||||
}
|
||||
|
||||
render := func() {
|
||||
err := loginTOTPTemplate.Execute(w, loginTOTPData{ 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 (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||
controller.SetSessionError(app.DB, session, "Invalid 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 = 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
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), "account", account)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -123,3 +463,36 @@ func staticHandler() http.Handler {
|
|||
http.FileServer(http.Dir(filepath.Join("admin", "static"))).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.DB, 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))
|
||||
})
|
||||
}
|
||||
|
|
67
admin/logshttp.go
Normal file
67
admin/logshttp.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func logsHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
levelFilter := []log.LogLevel{}
|
||||
typeFilter := []string{}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
for key, value := range r.URL.Query() {
|
||||
if strings.HasPrefix(key, "level-") && value[0] == "on" {
|
||||
m := map[string]log.LogLevel{
|
||||
"info": log.LEVEL_INFO,
|
||||
"warn": log.LEVEL_WARN,
|
||||
}
|
||||
level, ok := m[strings.TrimPrefix(key, "level-")]
|
||||
if ok {
|
||||
levelFilter = append(levelFilter, level)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, "type-") && value[0] == "on" {
|
||||
typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-")))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type LogsResponse struct {
|
||||
Session *model.Session
|
||||
Logs []*log.Log
|
||||
}
|
||||
|
||||
err = logsTemplate.Execute(w, LogsResponse{
|
||||
Session: session,
|
||||
Logs: logs,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -5,25 +5,24 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func serveRelease() http.Handler {
|
||||
func serveRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slices := strings.Split(r.URL.Path[1:], "/")
|
||||
releaseID := slices[0]
|
||||
|
||||
account := r.Context().Value("account").(*model.Account)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
release, err := controller.GetRelease(global.DB, releaseID, true)
|
||||
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("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
|
||||
fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -34,10 +33,10 @@ func serveRelease() http.Handler {
|
|||
serveEditCredits(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "addcredit":
|
||||
serveAddCredit(release).ServeHTTP(w, r)
|
||||
serveAddCredit(app, release).ServeHTTP(w, r)
|
||||
return
|
||||
case "newcredit":
|
||||
serveNewCredit().ServeHTTP(w, r)
|
||||
serveNewCredit(app).ServeHTTP(w, r)
|
||||
return
|
||||
case "editlinks":
|
||||
serveEditLinks(release).ServeHTTP(w, r)
|
||||
|
@ -46,10 +45,10 @@ func serveRelease() http.Handler {
|
|||
serveEditTracks(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "addtrack":
|
||||
serveAddTrack(release).ServeHTTP(w, r)
|
||||
serveAddTrack(app, release).ServeHTTP(w, r)
|
||||
return
|
||||
case "newtrack":
|
||||
serveNewTrack().ServeHTTP(w, r)
|
||||
serveNewTrack(app).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
|
@ -57,12 +56,12 @@ func serveRelease() http.Handler {
|
|||
}
|
||||
|
||||
type ReleaseResponse struct {
|
||||
Account *model.Account
|
||||
Session *model.Session
|
||||
Release *model.Release
|
||||
}
|
||||
|
||||
err = pages["release"].Execute(w, ReleaseResponse{
|
||||
Account: account,
|
||||
err = releaseTemplate.Execute(w, ReleaseResponse{
|
||||
Session: session,
|
||||
Release: release,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -75,7 +74,7 @@ func serveRelease() http.Handler {
|
|||
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 := components["editcredits"].Execute(w, release)
|
||||
err := editCreditsTemplate.Execute(w, release)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -83,11 +82,11 @@ func serveEditCredits(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveAddCredit(release *model.Release) http.Handler {
|
||||
func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.ID)
|
||||
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -98,7 +97,7 @@ func serveAddCredit(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err = components["addcredit"].Execute(w, response{
|
||||
err = addCreditTemplate.Execute(w, response{
|
||||
ReleaseID: release.ID,
|
||||
Artists: artists,
|
||||
})
|
||||
|
@ -109,12 +108,12 @@ func serveAddCredit(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveNewCredit() 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]
|
||||
artist, err := controller.GetArtist(global.DB, artistID)
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
|
||||
fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -124,7 +123,7 @@ func serveNewCredit() http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err = components["newcredit"].Execute(w, artist)
|
||||
err = newCreditTemplate.Execute(w, artist)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -135,7 +134,7 @@ func serveNewCredit() http.Handler {
|
|||
func serveEditLinks(release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err := components["editlinks"].Execute(w, release)
|
||||
err := editLinksTemplate.Execute(w, release)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -146,7 +145,16 @@ func serveEditLinks(release *model.Release) http.Handler {
|
|||
func serveEditTracks(release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err := components["edittracks"].Execute(w, release)
|
||||
|
||||
type editTracksData struct {
|
||||
Release *model.Release
|
||||
Add func(a int, b int) int
|
||||
}
|
||||
|
||||
err := editTracksTemplate.Execute(w, editTracksData{
|
||||
Release: release,
|
||||
Add: func(a, b int) int { return a + b },
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -154,11 +162,11 @@ func serveEditTracks(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveAddTrack(release *model.Release) http.Handler {
|
||||
func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.ID)
|
||||
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -169,7 +177,7 @@ func serveAddTrack(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err = components["addtrack"].Execute(w, response{
|
||||
err = addTrackTemplate.Execute(w, response{
|
||||
ReleaseID: release.ID,
|
||||
Tracks: tracks,
|
||||
})
|
||||
|
@ -181,10 +189,10 @@ func serveAddTrack(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func serveNewTrack() 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]
|
||||
track, err := controller.GetTrack(global.DB, trackID)
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -196,7 +204,7 @@ func serveNewTrack() http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err = components["newtrack"].Execute(w, track)
|
||||
err = newTrackTemplate.Execute(w, track)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
|
@ -24,7 +24,7 @@ nav {
|
|||
justify-content: left;
|
||||
|
||||
background: #f8f8f8;
|
||||
border-radius: .5em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
nav .icon {
|
||||
|
@ -85,6 +85,15 @@ a img.icon {
|
|||
height: .8em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #303030;
|
||||
color: #f0f0f0;
|
||||
padding: .23em .3em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.card {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
@ -93,13 +102,6 @@ a img.icon {
|
|||
margin: 0 0 .5em 0;
|
||||
}
|
||||
|
||||
/*
|
||||
.card h3,
|
||||
.card p {
|
||||
margin: 0;
|
||||
}
|
||||
*/
|
||||
|
||||
.card-title {
|
||||
margin-bottom: 1em;
|
||||
display: flex;
|
||||
|
@ -127,20 +129,34 @@ a img.icon {
|
|||
|
||||
|
||||
|
||||
#message,
|
||||
#error {
|
||||
background: #ffa9b8;
|
||||
border: 1px solid #dc5959;
|
||||
margin: 0 0 1em 0;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #888;
|
||||
}
|
||||
#message {
|
||||
background: #a9dfff;
|
||||
border-color: #599fdc;
|
||||
}
|
||||
#error {
|
||||
background: #ffa9b8;
|
||||
border-color: #dc5959;
|
||||
}
|
||||
|
||||
|
||||
|
||||
a.delete:not(.button) {
|
||||
color: #d22828;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: .5em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #f0f0f0;
|
||||
color: inherit;
|
||||
|
@ -154,35 +170,59 @@ button:active, .button:active {
|
|||
border-color: #808080;
|
||||
}
|
||||
|
||||
button {
|
||||
.button, button {
|
||||
color: inherit;
|
||||
}
|
||||
button.new {
|
||||
.button.new, button.new {
|
||||
background: #c4ff6a;
|
||||
border-color: #84b141;
|
||||
}
|
||||
button.save {
|
||||
.button.save, button.save {
|
||||
background: #6fd7ff;
|
||||
border-color: #6f9eb0;
|
||||
}
|
||||
button.delete {
|
||||
.button.delete, button.delete {
|
||||
background: #ff7171;
|
||||
border-color: #7d3535;
|
||||
}
|
||||
button:hover {
|
||||
.button:hover, button:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
button:active {
|
||||
.button:active, button:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
}
|
||||
button[disabled] {
|
||||
.button[disabled], button[disabled] {
|
||||
background: #d0d0d0 !important;
|
||||
border-color: #808080 !important;
|
||||
opacity: .5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
a.delete {
|
||||
color: #d22828;
|
||||
|
||||
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
form label {
|
||||
width: 100%;
|
||||
margin: 1rem 0 .5rem 0;
|
||||
display: block;
|
||||
color: #10101080;
|
||||
}
|
||||
form input {
|
||||
margin: .5rem 0;
|
||||
padding: .3rem .5rem;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
input[disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
|
@ -1,28 +1,18 @@
|
|||
@import url("/admin/static/index.css");
|
||||
|
||||
form#change-password {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
form div {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
form button {
|
||||
margin-top: 1rem;
|
||||
div.card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin: 1rem 0 .5rem 0;
|
||||
display: block;
|
||||
color: #10101080;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
width: min(20rem, calc(100% - 1rem));
|
||||
margin: .5rem 0;
|
||||
padding: .3rem .5rem;
|
||||
display: block;
|
||||
|
@ -33,18 +23,11 @@ input {
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
#error {
|
||||
background: #ffa9b8;
|
||||
border: 1px solid #dc5959;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mfa-device {
|
||||
padding: .75em;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: .5em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
@ -9,7 +9,7 @@ h1 {
|
|||
flex-direction: row;
|
||||
gap: 1.2em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ input[type="text"] {
|
|||
flex-direction: row;
|
||||
gap: 1.2em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
@ -160,7 +160,7 @@ dialog div.dialog-actions {
|
|||
align-items: center;
|
||||
gap: 1em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
@ -170,7 +170,7 @@ dialog div.dialog-actions {
|
|||
}
|
||||
|
||||
.card.credits .credit .artist-avatar {
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card.credits .credit .artist-name {
|
||||
|
@ -196,7 +196,7 @@ dialog div.dialog-actions {
|
|||
align-items: center;
|
||||
gap: 1em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
@ -215,7 +215,7 @@ dialog div.dialog-actions {
|
|||
}
|
||||
|
||||
#editcredits .credit .artist-avatar {
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#editcredits .credit .credit-info {
|
||||
|
@ -228,12 +228,14 @@ dialog div.dialog-actions {
|
|||
}
|
||||
|
||||
#editcredits .credit .credit-info .credit-attribute label {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
|
||||
margin-left: .25em;
|
||||
margin: 0 0 0 .25em;
|
||||
padding: .2em .4em;
|
||||
flex-grow: 1;
|
||||
font-family: inherit;
|
||||
|
@ -241,6 +243,9 @@ dialog div.dialog-actions {
|
|||
border-radius: 4px;
|
||||
color: inherit;
|
||||
}
|
||||
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
|
||||
margin: 0 .3em;
|
||||
}
|
||||
|
||||
#editcredits .credit .artist-name {
|
||||
font-weight: bold;
|
||||
|
@ -369,8 +374,10 @@ dialog div.dialog-actions {
|
|||
#editlinks td input[type="text"] {
|
||||
width: calc(100% - .6em);
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0 .3em;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
|
@ -393,7 +400,7 @@ dialog div.dialog-actions {
|
|||
flex-direction: column;
|
||||
gap: .5em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ h1 {
|
|||
flex-direction: row;
|
||||
gap: 1.2em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
|
|
@ -1,23 +1,5 @@
|
|||
@import url("/admin/static/release-list-item.css");
|
||||
|
||||
.create-btn {
|
||||
background: #c4ff6a;
|
||||
padding: .5em .8em;
|
||||
border-radius: .5em;
|
||||
border: 1px solid #84b141;
|
||||
text-decoration: none;
|
||||
}
|
||||
.create-btn:hover {
|
||||
background: #fff;
|
||||
border-color: #d0d0d0;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
.create-btn:active {
|
||||
background: #d0d0d0;
|
||||
border-color: #808080;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.artist {
|
||||
margin-bottom: .5em;
|
||||
padding: .5em;
|
||||
|
@ -26,7 +8,7 @@
|
|||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
@ -49,7 +31,7 @@
|
|||
flex-direction: column;
|
||||
gap: .5em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
|
86
admin/static/logs.css
Normal file
86
admin/static/logs.css
Normal file
|
@ -0,0 +1,86 @@
|
|||
main {
|
||||
width: min(1080px, calc(100% - 2em))!important
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
div#search {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#search input {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
|
||||
border-right: none;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
#search button {
|
||||
padding: 0 .5em;
|
||||
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
form #filters p {
|
||||
margin: .5em 0 0 0;
|
||||
}
|
||||
form #filters label {
|
||||
display: inline;
|
||||
}
|
||||
form #filters input {
|
||||
margin-right: 1em;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#logs {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#logs tr {
|
||||
}
|
||||
|
||||
#logs tr td {
|
||||
border-bottom: 1px solid #8888;
|
||||
}
|
||||
|
||||
#logs tr td:nth-child(even) {
|
||||
background: #00000004;
|
||||
}
|
||||
|
||||
#logs th, #logs td {
|
||||
padding: .4em .8em;
|
||||
}
|
||||
|
||||
td, th {
|
||||
width: 1%;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.log-level,
|
||||
th.log-level,
|
||||
td.log-type,
|
||||
th.log-type {
|
||||
text-align: center;
|
||||
}
|
||||
td.log-content,
|
||||
td.log-content {
|
||||
width: 100%;
|
||||
white-space: collapse;
|
||||
}
|
||||
|
||||
.log:hover {
|
||||
background: #fff8;
|
||||
}
|
||||
|
||||
.log.warn {
|
||||
background: #ffe86a;
|
||||
}
|
||||
.log.warn:hover {
|
||||
background: #ffec81;
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
flex-direction: row;
|
||||
gap: 1em;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
@ -50,7 +50,7 @@
|
|||
padding: .5em;
|
||||
display: block;
|
||||
|
||||
border-radius: .5em;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #f0f0f0;
|
||||
background: #303030;
|
||||
|
@ -73,7 +73,7 @@
|
|||
padding: .3em .5em;
|
||||
display: inline-block;
|
||||
|
||||
border-radius: .3em;
|
||||
border-radius: 4px;
|
||||
background: #e0e0e0;
|
||||
|
||||
transition: color .1s, background .1s;
|
||||
|
|
|
@ -1,65 +1,125 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"arimelody-web/log"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var pages = map[string]*template.Template{
|
||||
"index": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||
filepath.Join("admin", "views", "index.html"),
|
||||
)),
|
||||
var indexTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||
filepath.Join("admin", "views", "index.html"),
|
||||
))
|
||||
|
||||
"login": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "login.html"),
|
||||
)),
|
||||
"create-account": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "create-account.html"),
|
||||
)),
|
||||
"logout": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "logout.html"),
|
||||
)),
|
||||
"account": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-account.html"),
|
||||
)),
|
||||
var loginTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "login.html"),
|
||||
))
|
||||
var loginTOTPTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "login-totp.html"),
|
||||
))
|
||||
var registerTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "register.html"),
|
||||
))
|
||||
var logoutTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "logout.html"),
|
||||
))
|
||||
var accountTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-account.html"),
|
||||
))
|
||||
var totpSetupTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "totp-setup.html"),
|
||||
))
|
||||
var totpConfirmTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "totp-confirm.html"),
|
||||
))
|
||||
|
||||
"release": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-release.html"),
|
||||
)),
|
||||
"artist": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-artist.html"),
|
||||
)),
|
||||
"track": template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||
filepath.Join("admin", "views", "edit-track.html"),
|
||||
)),
|
||||
}
|
||||
var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
|
||||
"parseLevel": func(level log.LogLevel) string {
|
||||
switch level {
|
||||
case log.LEVEL_INFO:
|
||||
return "INFO"
|
||||
case log.LEVEL_WARN:
|
||||
return "WARN"
|
||||
}
|
||||
return fmt.Sprintf("%d?", level)
|
||||
},
|
||||
"titleCase": func(logType string) string {
|
||||
runes := []rune(logType)
|
||||
for i, r := range runes {
|
||||
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
|
||||
runes[i] = r + ('A' - 'a')
|
||||
}
|
||||
}
|
||||
return string(runes)
|
||||
},
|
||||
"lower": func(str string) string { return strings.ToLower(str) },
|
||||
"prettyTime": func(t time.Time) string {
|
||||
// return t.Format("2006-01-02 15:04:05")
|
||||
// return t.Format("15:04:05, 2 Jan 2006")
|
||||
return t.Format("02 Jan 2006, 15:04:05")
|
||||
},
|
||||
}).ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "logs.html"),
|
||||
))
|
||||
|
||||
var components = map[string]*template.Template{
|
||||
"editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))),
|
||||
"addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))),
|
||||
"newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))),
|
||||
var releaseTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-release.html"),
|
||||
))
|
||||
var artistTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "views", "edit-artist.html"),
|
||||
))
|
||||
var trackTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "views", "layout.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||
filepath.Join("admin", "views", "edit-track.html"),
|
||||
))
|
||||
|
||||
"editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))),
|
||||
var editCreditsTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "credits", "editcredits.html"),
|
||||
))
|
||||
var addCreditTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "credits", "addcredit.html"),
|
||||
))
|
||||
var newCreditTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "credits", "newcredit.html"),
|
||||
))
|
||||
|
||||
"edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))),
|
||||
"addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))),
|
||||
"newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))),
|
||||
}
|
||||
var editLinksTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "links", "editlinks.html"),
|
||||
))
|
||||
|
||||
var editTracksTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "tracks", "edittracks.html"),
|
||||
))
|
||||
var addTrackTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "tracks", "addtrack.html"),
|
||||
))
|
||||
var newTrackTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("admin", "components", "tracks", "newtrack.html"),
|
||||
))
|
||||
|
|
|
@ -5,16 +5,15 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/controller"
|
||||
)
|
||||
|
||||
func serveTrack() http.Handler {
|
||||
func serveTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slices := strings.Split(r.URL.Path[1:], "/")
|
||||
id := slices[0]
|
||||
track, err := controller.GetTrack(global.DB, id)
|
||||
track, err := controller.GetTrack(app.DB, id)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -25,7 +24,7 @@ func serveTrack() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
releases, err := controller.GetTrackReleases(global.DB, track.ID, true)
|
||||
releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -33,15 +32,15 @@ func serveTrack() http.Handler {
|
|||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
Account *model.Account
|
||||
Session *model.Session
|
||||
Track *model.Track
|
||||
Releases []*model.Release
|
||||
}
|
||||
|
||||
account := r.Context().Value("account").(*model.Account)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err = pages["track"].Execute(w, TrackResponse{
|
||||
Account: account,
|
||||
err = trackTemplate.Execute(w, TrackResponse{
|
||||
Session: session,
|
||||
Track: track,
|
||||
Releases: releases,
|
||||
})
|
||||
|
|
|
@ -6,23 +6,27 @@
|
|||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Account Settings ({{.Account.Username}})</h1>
|
||||
{{if .Session.Message.Valid}}
|
||||
<p id="message">{{html .Session.Message.String}}</p>
|
||||
{{end}}
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
<h1>Account Settings ({{.Session.Account.Username}})</h1>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form action="/api/v1/change-password" method="POST" id="change-password">
|
||||
<div>
|
||||
<label for="current-password">Current Password</label>
|
||||
<input type="password" name="current-password" value="" autocomplete="current-password">
|
||||
<form action="/admin/account/password" method="POST" id="change-password">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
|
||||
|
||||
<label for="new-password">Password</label>
|
||||
<input type="password" name="new-password" value="" autocomplete="new-password">
|
||||
<label for="new-password">New Password</label>
|
||||
<input type="password" id="new-password" name="new-password" value="" autocomplete="new-password" required>
|
||||
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input type="password" name="confirm-password" value="" autocomplete="new-password">
|
||||
</div>
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
|
||||
|
||||
<button type="submit" class="save">Change Password</button>
|
||||
</form>
|
||||
|
@ -36,11 +40,11 @@
|
|||
{{range .TOTPs}}
|
||||
<div class="mfa-device">
|
||||
<div>
|
||||
<p class="mfa-device-name">{{.Name}}</p>
|
||||
<p class="mfa-device-date">Added: {{.CreatedAt}}</p>
|
||||
<p class="mfa-device-name">{{.TOTP.Name}}</p>
|
||||
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a class="delete">Delete</a>
|
||||
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
@ -48,7 +52,10 @@
|
|||
<p>You have no MFA devices.</p>
|
||||
{{end}}
|
||||
|
||||
<button type="submit" class="new" id="add-mfa-device">Add MFA Device</button>
|
||||
<div>
|
||||
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
|
||||
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
|
@ -58,9 +65,17 @@
|
|||
<p>
|
||||
Clicking the button below will delete your account.
|
||||
This action is <strong>irreversible</strong>.
|
||||
You will be prompted to confirm this decision.
|
||||
You will need to enter your password and TOTP below.
|
||||
</p>
|
||||
<button class="delete" id="delete">Delete Account</button>
|
||||
<form action="/admin/account/delete" method="POST">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" value="" autocomplete="current-password" required>
|
||||
|
||||
<label for="totp">TOTP</label>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
|
||||
|
||||
<button type="submit" class="delete">Delete Account</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
|
|
@ -36,13 +36,13 @@
|
|||
{{if .Credits}}
|
||||
{{range .Credits}}
|
||||
<div class="credit">
|
||||
<img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
||||
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
||||
<div class="credit-info">
|
||||
<h3 class="credit-name"><a href="/admin/release/{{.Artist.Release.ID}}">{{.Artist.Release.Title}}</a></h3>
|
||||
<p class="credit-artists">{{.Artist.Release.PrintArtists true true}}</p>
|
||||
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
||||
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
|
||||
<p class="artist-role">
|
||||
Role: {{.Artist.Role}}
|
||||
{{if .Artist.Primary}}
|
||||
Role: {{.Role}}
|
||||
{{if .Primary}}
|
||||
<small>(Primary)</small>
|
||||
{{end}}
|
||||
</p>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<div class="card-title">
|
||||
<h1>Releases</h1>
|
||||
<a class="create-btn" id="create-release">Create New</a>
|
||||
<a class="button new" id="create-release">Create New</a>
|
||||
</div>
|
||||
<div class="card releases">
|
||||
{{range .Releases}}
|
||||
|
@ -22,7 +22,7 @@
|
|||
|
||||
<div class="card-title">
|
||||
<h1>Artists</h1>
|
||||
<a class="create-btn" id="create-artist">Create New</a>
|
||||
<a class="button new" id="create-artist">Create New</a>
|
||||
</div>
|
||||
<div class="card artists">
|
||||
{{range $Artist := .Artists}}
|
||||
|
@ -38,7 +38,7 @@
|
|||
|
||||
<div class="card-title">
|
||||
<h1>Tracks</h1>
|
||||
<a class="create-btn" id="create-track">Create New</a>
|
||||
<a class="button new" id="create-track">Create New</a>
|
||||
</div>
|
||||
<div class="card tracks">
|
||||
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
|
||||
|
|
|
@ -23,10 +23,20 @@
|
|||
<div class="nav-item">
|
||||
<a href="/admin">home</a>
|
||||
</div>
|
||||
<div class="flex-fill"></div>
|
||||
{{if .Account}}
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a>
|
||||
<a href="/admin/logs">logs</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex-fill"></div>
|
||||
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logout" id="logout">log out</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="nav-item">
|
||||
|
|
47
admin/views/login-totp.html
Normal file
47
admin/views/login-totp.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
{{define "head"}}
|
||||
<title>Login - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
form#login-totp {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
form div {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
form button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: calc(100% - 1rem - 2px);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .Session.Message.Valid}}
|
||||
<p id="message">{{html .Session.Message.String}}</p>
|
||||
{{end}}
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/totp" method="POST" id="login-totp">
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
|
||||
<div>
|
||||
<label for="totp">TOTP</label>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save">Login</button>
|
||||
</form>
|
||||
</main>
|
||||
{{end}}
|
|
@ -3,15 +3,7 @@
|
|||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
p a {
|
||||
color: #2a67c8;
|
||||
}
|
||||
|
||||
a.discord {
|
||||
color: #5865F2;
|
||||
}
|
||||
|
||||
form {
|
||||
form#login {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -26,61 +18,33 @@ form button {
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin: 1rem 0 .5rem 0;
|
||||
display: block;
|
||||
color: #10101080;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
margin: .5rem 0;
|
||||
padding: .3rem .5rem;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
input[disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
width: calc(100% - 1rem - 2px);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .Message}}
|
||||
<p id="error">{{.Message}}</p>
|
||||
{{if .Session.Message.Valid}}
|
||||
<p id="message">{{html .Session.Message.String}}</p>
|
||||
{{end}}
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Token}}
|
||||
|
||||
<meta http-equiv="refresh" content="0;url=/admin/" />
|
||||
<p>
|
||||
Logged in successfully.
|
||||
You should be redirected to <a href="/admin">/admin</a> soon.
|
||||
</p>
|
||||
|
||||
{{else}}
|
||||
|
||||
<form action="/admin/login" method="POST" id="login">
|
||||
<h1>Log In</h1>
|
||||
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" value="" autocomplete="username">
|
||||
<input type="text" name="username" value="" autocomplete="username" required autofocus>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" value="" autocomplete="current-password">
|
||||
|
||||
<label for="totp">TOTP</label>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code">
|
||||
<input type="password" name="password" value="" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save">Login</button>
|
||||
</form>
|
||||
|
||||
{{end}}
|
||||
</main>
|
||||
{{end}}
|
||||
|
|
|
@ -12,13 +12,10 @@ p a {
|
|||
{{define "content"}}
|
||||
<main>
|
||||
|
||||
<meta http-equiv="refresh" content="5;url=/" />
|
||||
<meta http-equiv="refresh" content="0;url=/admin/login" />
|
||||
<p>
|
||||
Logged out successfully.
|
||||
You should be redirected to <a href="/">/</a> in 5 seconds.
|
||||
<script>
|
||||
localStorage.removeItem("arime-token");
|
||||
</script>
|
||||
You should be redirected to <a href="/admin/login">/admin/login</a> shortly.
|
||||
</p>
|
||||
|
||||
</main>
|
||||
|
|
68
admin/views/logs.html
Normal file
68
admin/views/logs.html
Normal file
|
@ -0,0 +1,68 @@
|
|||
{{define "head"}}
|
||||
<title>Audit Logs - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<link rel="stylesheet" href="/admin/static/logs.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Audit Logs</h1>
|
||||
|
||||
<form action="/admin/logs" method="GET">
|
||||
<div id="search">
|
||||
<input type="text" name="q" value="" placeholder="Filter by message...">
|
||||
<button type="submit" class="save">Search</button>
|
||||
</div>
|
||||
<div id="filters">
|
||||
<div>
|
||||
<p>Level:</p>
|
||||
<label for="level-info">Info</label>
|
||||
<input type="checkbox" name="level-info" id="level-info">
|
||||
<label for="level-warn">Warning</label>
|
||||
<input type="checkbox" name="level-warn" id="level-warn">
|
||||
</div>
|
||||
<div>
|
||||
<p>Type:</p>
|
||||
<label for="type-account">Account</label>
|
||||
<input type="checkbox" name="type-account" id="type-account">
|
||||
<label for="type-music">Music</label>
|
||||
<input type="checkbox" name="type-music" id="type-music">
|
||||
<label for="type-artist">Artist</label>
|
||||
<input type="checkbox" name="type-artist" id="type-artist">
|
||||
<label for="type-blog">Blog</label>
|
||||
<input type="checkbox" name="type-blog" id="type-blog">
|
||||
<label for="type-artwork">Artwork</label>
|
||||
<input type="checkbox" name="type-artwork" id="type-artwork">
|
||||
<label for="type-files">Files</label>
|
||||
<input type="checkbox" name="type-files" id="type-files">
|
||||
<label for="type-misc">Misc</label>
|
||||
<input type="checkbox" name="type-misc" id="type-misc">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<table id="logs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="log-time">Time</th>
|
||||
<th class="log-level">Level</th>
|
||||
<th class="log-type">Type</th>
|
||||
<th class="log-content">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Logs}}
|
||||
<tr class="log {{lower (parseLevel .Level)}}">
|
||||
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
||||
<td class="log-level">{{parseLevel .Level}}</td>
|
||||
<td class="log-type">{{titleCase .Type}}</td>
|
||||
<td class="log-content">{{.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
{{end}}
|
|
@ -11,7 +11,7 @@ a.discord {
|
|||
color: #5865F2;
|
||||
}
|
||||
|
||||
form {
|
||||
form#register {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -26,45 +26,33 @@ form button {
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin: 1rem 0 .5rem 0;
|
||||
display: block;
|
||||
color: #10101080;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
margin: .5rem 0;
|
||||
padding: .3rem .5rem;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
width: calc(100% - 1rem - 2px);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .Message}}
|
||||
<p id="error">{{.Message}}</p>
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/register" method="POST" id="create-account">
|
||||
<form action="/admin/register" method="POST" id="register">
|
||||
<h1>Create Account</h1>
|
||||
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" value="">
|
||||
<input type="text" name="username" value="" autocomplete="username" required autofocus>
|
||||
|
||||
<label for="email">Email</label>
|
||||
<input type="text" name="email" value="">
|
||||
<input type="text" name="email" value="" autocomplete="email" required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" value="">
|
||||
<input type="password" name="password" value="" autocomplete="new-password" required>
|
||||
|
||||
<label for="invite">Invite Code</label>
|
||||
<input type="text" name="invite" value="">
|
||||
<input type="text" name="invite" value="" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="new">Create Account</button>
|
48
admin/views/totp-confirm.html
Normal file
48
admin/views/totp-confirm.html
Normal file
|
@ -0,0 +1,48 @@
|
|||
{{define "head"}}
|
||||
<title>TOTP Confirmation - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
.qr-code {
|
||||
border: 1px solid #8888;
|
||||
}
|
||||
code {
|
||||
user-select: all;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
|
||||
{{if .QRBase64Image}}
|
||||
<img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code">
|
||||
|
||||
<p>
|
||||
Scan the QR code above into your authentication app or password manager,
|
||||
then enter your 2FA code below.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If the QR code does not work, you may also enter this secret code:
|
||||
</p>
|
||||
{{else}}
|
||||
<p>
|
||||
Paste the below secret code into your authentication app or password manager,
|
||||
then enter your 2FA code below:
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<p><code>{{.TOTP.Secret}}</code></p>
|
||||
|
||||
<label for="totp">TOTP:</label>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
||||
|
||||
<button type="submit" class="new">Create</button>
|
||||
</form>
|
||||
</main>
|
||||
{{end}}
|
20
admin/views/totp-setup.html
Normal file
20
admin/views/totp-setup.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{{define "head"}}
|
||||
<title>TOTP Setup - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/account/totp-setup" method="POST" id="totp-setup">
|
||||
<label for="totp-name">TOTP Device Name:</label>
|
||||
<input type="text" name="totp-name" value="" autocomplete="off" required autofocus>
|
||||
|
||||
<button type="submit" class="new">Create</button>
|
||||
</form>
|
||||
</main>
|
||||
{{end}}
|
202
api/account.go
202
api/account.go
|
@ -1,202 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/global"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func handleLogin() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
credentials := LoginRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(LoginResponse{
|
||||
Token: token.Token,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleAccountRegistration() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
|
||||
credentials := RegisterRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// make sure code exists in DB
|
||||
invite, err := controller.GetInvite(global.DB, credentials.Invite)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if invite == nil {
|
||||
http.Error(w, "Invalid invite code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(invite.ExpiresAt) {
|
||||
err := controller.DeleteInvite(global.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
http.Error(w, "Invalid invite code", http.StatusBadRequest)
|
||||
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)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: string(hashedPassword),
|
||||
Email: credentials.Email,
|
||||
AvatarURL: "/img/default-avatar.png",
|
||||
}
|
||||
err = controller.CreateAccount(global.DB, &account)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
http.Error(w, "An account with that username already exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteInvite(global.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
|
||||
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(LoginResponse{
|
||||
Token: token.Token,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteAccount() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
credentials := LoginRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: check TOTP
|
||||
|
||||
err = controller.DeleteAccount(global.DB, account.Username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Account deleted successfully\n"))
|
||||
})
|
||||
}
|
117
api/api.go
117
api/api.go
|
@ -1,44 +1,33 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/controller"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func Handler(db *sqlx.DB) http.Handler {
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// ACCOUNT ENDPOINTS
|
||||
|
||||
/*
|
||||
// temporarily disabling these
|
||||
// accounts should really be handled via the frontend rn, and juggling
|
||||
// two different token bearer methods kinda sucks!!
|
||||
// i'll look into generating API tokens on the frontend in the future
|
||||
// TODO: generate API keys on the frontend
|
||||
|
||||
mux.Handle("/v1/login", handleLogin())
|
||||
mux.Handle("/v1/register", handleAccountRegistration())
|
||||
mux.Handle("/v1/delete-account", handleDeleteAccount())
|
||||
*/
|
||||
|
||||
// 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(db, artistID)
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
|
||||
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -46,13 +35,13 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/artist/{id}
|
||||
ServeArtist(artist).ServeHTTP(w, r)
|
||||
ServeArtist(app, artist).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/artist/{id} (admin)
|
||||
admin.RequireAccount(db, UpdateArtist(artist)).ServeHTTP(w, r)
|
||||
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/artist/{id} (admin)
|
||||
admin.RequireAccount(db, DeleteArtist(artist)).ServeHTTP(w, r)
|
||||
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -61,10 +50,10 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/artist
|
||||
ServeAllArtists().ServeHTTP(w, r)
|
||||
ServeAllArtists(app).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/artist (admin)
|
||||
admin.RequireAccount(db, CreateArtist()).ServeHTTP(w, r)
|
||||
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -74,13 +63,13 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
|
||||
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(db, releaseID, true)
|
||||
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("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -88,13 +77,13 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/music/{id}
|
||||
ServeRelease(release).ServeHTTP(w, r)
|
||||
ServeRelease(app, release).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/music/{id} (admin)
|
||||
admin.RequireAccount(db, UpdateRelease(release)).ServeHTTP(w, r)
|
||||
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/music/{id} (admin)
|
||||
admin.RequireAccount(db, DeleteRelease(release)).ServeHTTP(w, r)
|
||||
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -103,10 +92,10 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/music
|
||||
ServeCatalog().ServeHTTP(w, r)
|
||||
ServeCatalog(app).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/music (admin)
|
||||
admin.RequireAccount(db, CreateRelease()).ServeHTTP(w, r)
|
||||
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -116,13 +105,13 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
|
||||
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(db, trackID)
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
|
||||
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -130,13 +119,13 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/track/{id} (admin)
|
||||
admin.RequireAccount(db, ServeTrack(track)).ServeHTTP(w, r)
|
||||
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/track/{id} (admin)
|
||||
admin.RequireAccount(db, UpdateTrack(track)).ServeHTTP(w, r)
|
||||
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/track/{id} (admin)
|
||||
admin.RequireAccount(db, DeleteTrack(track)).ServeHTTP(w, r)
|
||||
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -145,14 +134,66 @@ func Handler(db *sqlx.DB) http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/track (admin)
|
||||
admin.RequireAccount(db, ServeAllTracks()).ServeHTTP(w, r)
|
||||
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/track (admin)
|
||||
admin.RequireAccount(db, CreateTrack()).ServeHTTP(w, r)
|
||||
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
return mux
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := getSession(app, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to get session: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "session", session)
|
||||
mux.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func requireAccount(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
if session == nil || session.Account == nil {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "session", session)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
|
||||
var token string
|
||||
|
||||
// check cookies first
|
||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||
if err != nil && err != http.ErrNoCookie {
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err))
|
||||
}
|
||||
if sessionCookie != nil {
|
||||
token = sessionCookie.Value
|
||||
} else {
|
||||
// check Authorization header
|
||||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
|
||||
if token == "" { return nil, nil }
|
||||
|
||||
// fetch existing session
|
||||
session, err := controller.GetSession(app.DB, token)
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err))
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
// TODO: consider running security checks here (i.e. user agent mismatches)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
|
|
@ -10,15 +10,15 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func ServeAllArtists() http.Handler {
|
||||
func ServeAllArtists(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var artists = []*model.Artist{}
|
||||
artists, err := controller.GetAllArtists(global.DB)
|
||||
artists, err := controller.GetAllArtists(app.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -35,7 +35,7 @@ func ServeAllArtists() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeArtist(artist *model.Artist) http.Handler {
|
||||
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type (
|
||||
creditJSON struct {
|
||||
|
@ -52,15 +52,10 @@ func ServeArtist(artist *model.Artist) http.Handler {
|
|||
}
|
||||
)
|
||||
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
show_hidden_releases := account != nil
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
show_hidden_releases := session != nil && session.Account != nil
|
||||
|
||||
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
|
||||
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -92,8 +87,10 @@ func ServeArtist(artist *model.Artist) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func CreateArtist() http.Handler {
|
||||
func CreateArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var artist model.Artist
|
||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||
if err != nil {
|
||||
|
@ -107,7 +104,7 @@ func CreateArtist() http.Handler {
|
|||
}
|
||||
if artist.Name == "" { artist.Name = artist.ID }
|
||||
|
||||
err = controller.CreateArtist(global.DB, &artist)
|
||||
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)
|
||||
|
@ -118,12 +115,16 @@ func CreateArtist() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateArtist(artist *model.Artist) http.Handler {
|
||||
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to update artist: %s\n", err)
|
||||
|
@ -136,7 +137,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
} else {
|
||||
if strings.Contains(artist.Avatar, ";base64,") {
|
||||
var artworkDirectory = filepath.Join("uploads", "avatar")
|
||||
filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID)
|
||||
filename, err := HandleImageUpload(app, &artist.Avatar, artworkDirectory, artist.ID)
|
||||
|
||||
// clean up files with this ID and different extensions
|
||||
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
|
||||
|
@ -155,7 +156,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
err = controller.UpdateArtist(global.DB, artist)
|
||||
err = controller.UpdateArtist(app.DB, artist)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -164,12 +165,16 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteArtist(artist *model.Artist) http.Handler {
|
||||
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := controller.DeleteArtist(global.DB, artist.ID)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := controller.DeleteArtist(app.DB, artist.ID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -178,5 +183,7 @@ func DeleteArtist(artist *model.Artist) http.Handler {
|
|||
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -10,23 +10,24 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func ServeRelease(release *model.Release) http.Handler {
|
||||
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only allow authorised users to view hidden releases
|
||||
privileged := false
|
||||
if !release.Visible {
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
session, err := controller.GetSessionFromRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
|
||||
if session != nil && session.Account != nil {
|
||||
// TODO: check privilege on release
|
||||
privileged = true
|
||||
}
|
||||
|
@ -67,14 +68,14 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
|
||||
if release.IsReleased() || privileged {
|
||||
// get credits
|
||||
credits, err := controller.GetReleaseCredits(global.DB, release.ID)
|
||||
credits, err := controller.GetReleaseCredits(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, credit := range credits {
|
||||
artist, err := controller.GetArtist(global.DB, credit.Artist.ID)
|
||||
artist, err := controller.GetArtist(app.DB, credit.Artist.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -89,7 +90,7 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
// get tracks
|
||||
tracks, err := controller.GetReleaseTracks(global.DB, release.ID)
|
||||
tracks, err := controller.GetReleaseTracks(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -104,7 +105,7 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
// get links
|
||||
links, err := controller.GetReleaseLinks(global.DB, release.ID)
|
||||
links, err := controller.GetReleaseLinks(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -126,9 +127,9 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeCatalog() http.Handler {
|
||||
func ServeCatalog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
|
||||
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -146,16 +147,11 @@ func ServeCatalog() http.Handler {
|
|||
}
|
||||
|
||||
catalog := []Release{}
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
for _, release := range releases {
|
||||
if !release.Visible {
|
||||
privileged := false
|
||||
if account != nil {
|
||||
if session != nil && session.Account != nil {
|
||||
// TODO: check privilege on release
|
||||
privileged = true
|
||||
}
|
||||
|
@ -192,12 +188,9 @@ func ServeCatalog() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func CreateRelease() http.Handler {
|
||||
func CreateRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var release model.Release
|
||||
err := json.NewDecoder(r.Body).Decode(&release)
|
||||
|
@ -220,7 +213,7 @@ func CreateRelease() http.Handler {
|
|||
|
||||
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
|
||||
|
||||
err = controller.CreateRelease(global.DB, &release)
|
||||
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)
|
||||
|
@ -231,6 +224,8 @@ func CreateRelease() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
encoder := json.NewEncoder(w)
|
||||
|
@ -243,8 +238,10 @@ func CreateRelease() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateRelease(release *model.Release) http.Handler {
|
||||
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
if r.URL.Path == "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
@ -255,11 +252,11 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
if len(segments) == 2 {
|
||||
switch segments[1] {
|
||||
case "tracks":
|
||||
UpdateReleaseTracks(release).ServeHTTP(w, r)
|
||||
UpdateReleaseTracks(app, release).ServeHTTP(w, r)
|
||||
case "credits":
|
||||
UpdateReleaseCredits(release).ServeHTTP(w, r)
|
||||
UpdateReleaseCredits(app, release).ServeHTTP(w, r)
|
||||
case "links":
|
||||
UpdateReleaseLinks(release).ServeHTTP(w, r)
|
||||
UpdateReleaseLinks(app, release).ServeHTTP(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -281,7 +278,7 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
} else {
|
||||
if strings.Contains(release.Artwork, ";base64,") {
|
||||
var artworkDirectory = filepath.Join("uploads", "musicart")
|
||||
filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID)
|
||||
filename, err := HandleImageUpload(app, &release.Artwork, artworkDirectory, release.ID)
|
||||
|
||||
// clean up files with this ID and different extensions
|
||||
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
|
||||
|
@ -300,7 +297,7 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
err = controller.UpdateRelease(global.DB, release)
|
||||
err = controller.UpdateRelease(app.DB, release)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -309,11 +306,15 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseTracks(release *model.Release) http.Handler {
|
||||
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var trackIDs = []string{}
|
||||
err := json.NewDecoder(r.Body).Decode(&trackIDs)
|
||||
if err != nil {
|
||||
|
@ -321,7 +322,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
|
||||
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -330,11 +331,15 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
|
|||
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseCredits(release *model.Release) http.Handler {
|
||||
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
type creditJSON struct {
|
||||
Artist string
|
||||
Role string
|
||||
|
@ -358,7 +363,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
err = controller.UpdateReleaseCredits(global.DB, release.ID, credits)
|
||||
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)
|
||||
|
@ -371,15 +376,14 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
|
|||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseLinks(release *model.Release) http.Handler {
|
||||
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var links = []*model.Link{}
|
||||
err := json.NewDecoder(r.Body).Decode(&links)
|
||||
|
@ -388,7 +392,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.UpdateReleaseLinks(global.DB, release.ID, links)
|
||||
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -397,12 +401,16 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
|
|||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteRelease(release *model.Release) http.Handler {
|
||||
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := controller.DeleteRelease(global.DB, release.ID)
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := controller.DeleteRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
|
@ -411,5 +419,7 @@ func DeleteRelease(release *model.Release) http.Handler {
|
|||
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
|
41
api/track.go
41
api/track.go
|
@ -5,8 +5,8 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
|
@ -17,7 +17,7 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
func ServeAllTracks() http.Handler {
|
||||
func ServeAllTracks(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
|
@ -26,7 +26,7 @@ func ServeAllTracks() http.Handler {
|
|||
var tracks = []Track{}
|
||||
|
||||
var dbTracks = []*model.Track{}
|
||||
dbTracks, err := controller.GetAllTracks(global.DB)
|
||||
dbTracks, err := controller.GetAllTracks(app.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -50,9 +50,9 @@ func ServeAllTracks() http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeTrack(track *model.Track) http.Handler {
|
||||
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false)
|
||||
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)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -74,12 +74,9 @@ func ServeTrack(track *model.Track) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func CreateTrack() http.Handler {
|
||||
func CreateTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var track model.Track
|
||||
err := json.NewDecoder(r.Body).Decode(&track)
|
||||
|
@ -93,26 +90,30 @@ func CreateTrack() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
id, err := controller.CreateTrack(global.DB, &track)
|
||||
id, err := controller.CreateTrack(app.DB, &track)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to create track: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username)
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(id))
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateTrack(track *model.Track) http.Handler {
|
||||
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path == "/" {
|
||||
if r.URL.Path == "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&track)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
@ -124,13 +125,15 @@ func UpdateTrack(track *model.Track) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.UpdateTrack(global.DB, track)
|
||||
err = controller.UpdateTrack(app.DB, track)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
|
@ -141,18 +144,22 @@ func UpdateTrack(track *model.Track) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteTrack(track *model.Track) http.Handler {
|
||||
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path == "/" {
|
||||
if r.URL.Path == "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var trackID = r.URL.Path[1:]
|
||||
err := controller.DeleteTrack(global.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)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
@ -11,12 +12,12 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
func HandleImageUpload(data *string, directory string, filename string) (string, error) {
|
||||
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
|
||||
split := strings.Split(*data, ";base64,")
|
||||
header := split[0]
|
||||
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
||||
ext, _ := strings.CutPrefix(header, "data:image/")
|
||||
directory = filepath.Join(global.Config.DataDirectory, directory)
|
||||
directory = filepath.Join(app.Config.DataDirectory, directory)
|
||||
|
||||
switch ext {
|
||||
case "png":
|
||||
|
@ -49,5 +50,7 @@ func HandleImageUpload(data *string, directory string, filename string) (string,
|
|||
return "", nil
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
|
|
@ -6,4 +6,4 @@ if [ ! -f arimelody-web ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/
|
||||
tar czf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
@ -22,7 +18,21 @@ func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
|
|||
return accounts, nil
|
||||
}
|
||||
|
||||
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
|
||||
func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
|
||||
var account = model.Account{}
|
||||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) {
|
||||
var account = model.Account{}
|
||||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
|
||||
|
@ -50,12 +60,12 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
|
|||
return &account, nil
|
||||
}
|
||||
|
||||
func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
|
||||
if token == "" { return nil, nil }
|
||||
func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) {
|
||||
if sessionToken == "" { return nil, nil }
|
||||
|
||||
account := model.Account{}
|
||||
|
||||
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token)
|
||||
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
|
||||
|
@ -66,42 +76,6 @@ func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
|
|||
return &account, nil
|
||||
}
|
||||
|
||||
func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
|
||||
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if len(tokenStr) > 0 {
|
||||
return tokenStr
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(global.COOKIE_TOKEN)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
|
||||
tokenStr := GetTokenFromRequest(db, r)
|
||||
|
||||
token, err := GetToken(db, tokenStr)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("GetToken: " + err.Error())
|
||||
}
|
||||
|
||||
// does user-agent match the token?
|
||||
if r.UserAgent() != token.UserAgent {
|
||||
// invalidate the token
|
||||
DeleteToken(db, tokenStr)
|
||||
fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent())
|
||||
// TODO: log unauthorised activity to the user
|
||||
return nil, errors.New("User agent mismatch")
|
||||
}
|
||||
|
||||
return GetAccountByToken(db, tokenStr)
|
||||
}
|
||||
|
||||
func CreateAccount(db *sqlx.DB, account *model.Account) error {
|
||||
err := db.Get(
|
||||
&account.ID,
|
||||
|
@ -120,7 +94,7 @@ func CreateAccount(db *sqlx.DB, account *model.Account) error {
|
|||
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE account " +
|
||||
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
|
||||
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
|
||||
"WHERE id=$1",
|
||||
account.ID,
|
||||
account.Username,
|
||||
|
@ -132,7 +106,7 @@ func UpdateAccount(db *sqlx.DB, account *model.Account) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func DeleteAccount(db *sqlx.DB, username string) error {
|
||||
_, err := db.Exec("DELETE FROM account WHERE username=$1", username)
|
||||
func DeleteAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package global
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
@ -6,44 +6,23 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
type (
|
||||
dbConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
Name string `toml:"name"`
|
||||
User string `toml:"user"`
|
||||
Pass string `toml:"pass"`
|
||||
}
|
||||
|
||||
discordConfig struct {
|
||||
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
|
||||
ClientID string `toml:"client_id"`
|
||||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
config struct {
|
||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||
Port int64 `toml:"port"`
|
||||
DataDirectory string `toml:"data_dir"`
|
||||
DB dbConfig `toml:"db"`
|
||||
Discord discordConfig `toml:"discord"`
|
||||
}
|
||||
)
|
||||
|
||||
var Config = func() config {
|
||||
func GetConfig() model.Config {
|
||||
configFile := os.Getenv("ARIMELODY_CONFIG")
|
||||
if configFile == "" {
|
||||
configFile = "config.toml"
|
||||
}
|
||||
|
||||
config := config{
|
||||
config := model.Config{
|
||||
BaseUrl: "https://arimelody.me",
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
DB: dbConfig{
|
||||
TrustedProxies: []string{ "127.0.0.1" },
|
||||
DB: model.DBConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "arimelody",
|
||||
|
@ -63,23 +42,22 @@ var Config = func() config {
|
|||
|
||||
err = toml.Unmarshal([]byte(data), &config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
|
||||
os.Exit(1)
|
||||
panic(fmt.Sprintf("FATAL: Failed to parse configuration file: %v\n", err))
|
||||
}
|
||||
|
||||
err = handleConfigOverrides(&config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
|
||||
os.Exit(1)
|
||||
panic(fmt.Sprintf("FATAL: Failed to parse environment variable %v\n", err))
|
||||
}
|
||||
|
||||
return config
|
||||
}()
|
||||
}
|
||||
|
||||
func handleConfigOverrides(config *config) error {
|
||||
func handleConfigOverrides(config *model.Config) error {
|
||||
var err error
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_HOST"); has { config.Host = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_PORT"); has {
|
||||
config.Port, err = strconv.ParseInt(env, 10, 0)
|
||||
if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) }
|
||||
|
@ -101,5 +79,3 @@ func handleConfigOverrides(config *config) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
var DB *sqlx.DB
|
23
controller/ip.go
Normal file
23
controller/ip.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Returns the request's original IP address, resolving the `x-forwarded-for`
|
||||
// header if the request originates from a trusted proxy.
|
||||
func ResolveIP(app *model.AppState, r *http.Request) string {
|
||||
addr := strings.Split(r.RemoteAddr, ":")[0]
|
||||
if slices.Contains(app.Config.TrustedProxies, addr) {
|
||||
forwardedFor := r.Header.Get("x-forwarded-for")
|
||||
if len(forwardedFor) > 0 {
|
||||
// discard extra IPs; cloudflare tends to append their nodes
|
||||
forwardedFor = strings.Split(forwardedFor, ", ")[0]
|
||||
return forwardedFor
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const DB_VERSION int = 2
|
||||
const DB_VERSION int = 3
|
||||
|
||||
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
||||
|
@ -41,6 +41,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
|||
ApplyMigration(db, "001-pre-versioning")
|
||||
oldDBVersion = 2
|
||||
|
||||
case 2:
|
||||
ApplyMigration(db, "002-audit-logs")
|
||||
oldDBVersion = 3
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,7 +54,7 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
|||
func ApplyMigration(db *sqlx.DB, scriptFile string) {
|
||||
fmt.Printf("Applying schema migration %s...\n", scriptFile)
|
||||
|
||||
bytes, err := os.ReadFile("schema_migration/" + scriptFile + ".sql")
|
||||
bytes, err := os.ReadFile("schema-migration/" + scriptFile + ".sql")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
|
||||
os.Exit(1)
|
||||
|
|
120
controller/qr.go
Normal file
120
controller/qr.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
func GenerateQRCode(data string) (string, error) {
|
||||
imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base64Img := base64.StdEncoding.EncodeToString(imgBytes)
|
||||
return base64Img, nil
|
||||
}
|
||||
|
||||
// vvv DEPRECATED vvv
|
||||
|
||||
const margin = 4
|
||||
|
||||
type QRCodeECCLevel int64
|
||||
const (
|
||||
LOW QRCodeECCLevel = iota
|
||||
MEDIUM
|
||||
QUARTILE
|
||||
HIGH
|
||||
)
|
||||
|
||||
func noDepsGenerateQRCode() (string, error) {
|
||||
version := 1
|
||||
|
||||
size := 0
|
||||
size = 21 + version * 4
|
||||
if version > 10 {
|
||||
return "", errors.New(fmt.Sprintf("QR version %d not supported", version))
|
||||
}
|
||||
|
||||
img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2))
|
||||
|
||||
// fill white
|
||||
for y := range size + margin * 2 {
|
||||
for x := range size + margin * 2 {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
|
||||
// draw alignment squares
|
||||
drawLargeAlignmentSquare(margin, margin, img)
|
||||
drawLargeAlignmentSquare(margin, margin + size - 7, img)
|
||||
drawLargeAlignmentSquare(margin + size - 7, margin, img)
|
||||
drawSmallAlignmentSquare(size - 5, size - 5, img)
|
||||
/*
|
||||
if version > 4 {
|
||||
space := version * 3 - 2
|
||||
end := size / space
|
||||
for y := range size / space + 1 {
|
||||
for x := range size / space + 1 {
|
||||
if x == 0 && y == 0 { continue }
|
||||
if x == 0 && y == end { continue }
|
||||
if x == end && y == 0 { continue }
|
||||
if x == end && y == end { continue }
|
||||
drawSmallAlignmentSquare(
|
||||
x * space + margin + 4,
|
||||
y * space + margin + 4,
|
||||
img,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// draw timing bits
|
||||
for i := margin + 6; i < size - 4; i++ {
|
||||
if (i % 2 == 0) {
|
||||
img.Set(i, margin + 6, color.Black)
|
||||
img.Set(margin + 6, i, color.Black)
|
||||
}
|
||||
}
|
||||
img.Set(margin + 8, size - 4, color.Black)
|
||||
|
||||
var imgBuf bytes.Buffer
|
||||
err := png.Encode(&imgBuf, img)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes())
|
||||
|
||||
return "data:image/png;base64," + base64Img, nil
|
||||
}
|
||||
|
||||
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
|
||||
for yi := range 7 {
|
||||
for xi := range 7 {
|
||||
if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
|
||||
img.Set(x + xi, y + yi, color.Black)
|
||||
} else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
|
||||
img.Set(x + xi, y + yi, color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
|
||||
for yi := range 5 {
|
||||
for xi := range 5 {
|
||||
if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
|
||||
img.Set(x + xi, y + yi, color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
img.Set(x + 2, y + 2, color.Black)
|
||||
}
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
177
controller/session.go
Normal file
177
controller/session.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const TOKEN_LEN = 64
|
||||
|
||||
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) {
|
||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||
if err != nil && err != http.ErrNoCookie {
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
|
||||
}
|
||||
|
||||
var session *model.Session
|
||||
|
||||
if sessionCookie != nil {
|
||||
// fetch existing session
|
||||
session, err = GetSession(db, sessionCookie.Value)
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
// TODO: consider running security checks here (i.e. user agent mismatches)
|
||||
}
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
|
||||
tokenString := GenerateAlnumString(TOKEN_LEN)
|
||||
|
||||
session := model.Session{
|
||||
Token: string(tokenString),
|
||||
UserAgent: userAgent,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Hour * 24),
|
||||
}
|
||||
|
||||
_, err := db.Exec("INSERT INTO session " +
|
||||
"(token, user_agent, created_at, expires_at) VALUES " +
|
||||
"($1, $2, $3, $4)",
|
||||
session.Token,
|
||||
session.UserAgent,
|
||||
session.CreatedAt,
|
||||
session.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// func WriteSession(db *sqlx.DB, session *model.Session) error {
|
||||
// _, err := db.Exec(
|
||||
// "UPDATE session " +
|
||||
// "SET account=$2,message=$3,error=$4 " +
|
||||
// "WHERE token=$1",
|
||||
// session.Token,
|
||||
// session.Account.ID,
|
||||
// session.Message,
|
||||
// session.Error,
|
||||
// )
|
||||
// return err
|
||||
// }
|
||||
|
||||
func SetSessionAttemptAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
|
||||
var err error
|
||||
session.AttemptAccount = account
|
||||
if account == nil {
|
||||
_, err = db.Exec("UPDATE session SET attempt_account=NULL WHERE token=$1", session.Token)
|
||||
} else {
|
||||
_, err = db.Exec("UPDATE session SET attempt_account=$2 WHERE token=$1", session.Token, account.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
|
||||
var err error
|
||||
session.Account = account
|
||||
if account == nil {
|
||||
_, err = db.Exec("UPDATE session SET account=NULL WHERE token=$1", session.Token)
|
||||
} else {
|
||||
_, err = db.Exec("UPDATE session SET account=$2 WHERE token=$1", session.Token, account.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func SetSessionMessage(db *sqlx.DB, session *model.Session, message string) error {
|
||||
var err error
|
||||
if message == "" {
|
||||
if !session.Message.Valid { return nil }
|
||||
session.Message = sql.NullString{ }
|
||||
_, err = db.Exec("UPDATE session SET message=NULL WHERE token=$1", session.Token)
|
||||
} else {
|
||||
session.Message = sql.NullString{ String: message, Valid: true }
|
||||
_, err = db.Exec("UPDATE session SET message=$2 WHERE token=$1", session.Token, message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func SetSessionError(db *sqlx.DB, session *model.Session, message string) error {
|
||||
var err error
|
||||
if message == "" {
|
||||
if !session.Error.Valid { return nil }
|
||||
session.Error = sql.NullString{ }
|
||||
_, err = db.Exec("UPDATE session SET error=NULL WHERE token=$1", session.Token)
|
||||
} else {
|
||||
session.Error = sql.NullString{ String: message, Valid: true }
|
||||
_, err = db.Exec("UPDATE session SET error=$2 WHERE token=$1", session.Token, message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
|
||||
type dbSession struct {
|
||||
model.Session
|
||||
AttemptAccountID sql.NullString `db:"attempt_account"`
|
||||
AccountID sql.NullString `db:"account"`
|
||||
}
|
||||
|
||||
session := dbSession{}
|
||||
err := db.Get(
|
||||
&session,
|
||||
"SELECT * FROM session WHERE token=$1",
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.AccountID.Valid {
|
||||
session.Account, err = GetAccountByID(db, session.AccountID.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if session.AttemptAccountID.Valid {
|
||||
session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &session.Session, err
|
||||
}
|
||||
|
||||
// func GetAllSessionsForAccount(db *sqlx.DB, accountID string) ([]model.Session, error) {
|
||||
// sessions := []model.Session{}
|
||||
// err := db.Select(&sessions, "SELECT * FROM session WHERE account=$1 AND expires_at>current_timestamp", accountID)
|
||||
// return sessions, err
|
||||
// }
|
||||
|
||||
func DeleteAllSessionsForAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("DELETE FROM session WHERE account=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteSession(db *sqlx.DB, token string) error {
|
||||
_, err := db.Exec("DELETE FROM session WHERE token=$1", token)
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const TOKEN_LEN = 32
|
||||
|
||||
func CreateToken(db *sqlx.DB, accountID string, userAgent string) (*model.Token, error) {
|
||||
tokenString := GenerateAlnumString(TOKEN_LEN)
|
||||
|
||||
token := model.Token{
|
||||
Token: string(tokenString),
|
||||
AccountID: accountID,
|
||||
UserAgent: userAgent,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Hour * 24),
|
||||
}
|
||||
|
||||
_, err := db.Exec("INSERT INTO token " +
|
||||
"(token, account, user_agent, created_at, expires_at) VALUES " +
|
||||
"($1, $2, $3, $4, $5)",
|
||||
token.Token,
|
||||
token.AccountID,
|
||||
token.UserAgent,
|
||||
token.CreatedAt,
|
||||
token.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func GetToken(db *sqlx.DB, token_str string) (*model.Token, error) {
|
||||
token := model.Token{}
|
||||
err := db.Get(&token, "SELECT * FROM token WHERE token=$1", token_str)
|
||||
return &token, err
|
||||
}
|
||||
|
||||
func GetAllTokensForAccount(db *sqlx.DB, accountID string) ([]model.Token, error) {
|
||||
tokens := []model.Token{}
|
||||
err := db.Select(&tokens, "SELECT * FROM token WHERE account=$1 AND expires_at>current_timestamp", accountID)
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func DeleteAllTokensForAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("DELETE FROM token WHERE account=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteToken(db *sqlx.DB, token string) error {
|
||||
_, err := db.Exec("DELETE FROM token WHERE token=$1", token)
|
||||
return err
|
||||
}
|
||||
|
|
@ -18,8 +18,8 @@ import (
|
|||
)
|
||||
|
||||
const TOTP_SECRET_LENGTH = 32
|
||||
const TIME_STEP int64 = 30
|
||||
const CODE_LENGTH = 6
|
||||
const TOTP_TIME_STEP int64 = 30
|
||||
const TOTP_CODE_LENGTH = 6
|
||||
|
||||
func GenerateTOTP(secret string, timeStepOffset int) string {
|
||||
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
|
@ -27,7 +27,7 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
|
|||
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
|
||||
}
|
||||
|
||||
counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset)
|
||||
counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset)
|
||||
counterBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
|
||||
|
||||
|
@ -37,9 +37,9 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
|
|||
|
||||
offset := hash[len(hash) - 1] & 0x0f
|
||||
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
|
||||
code := binaryCode % int32(math.Pow10(CODE_LENGTH))
|
||||
code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH))
|
||||
|
||||
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code)
|
||||
return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code)
|
||||
}
|
||||
|
||||
func GenerateTOTPSecret(length int) string {
|
||||
|
@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string {
|
|||
query := url.Query()
|
||||
query.Set("secret", secret)
|
||||
query.Set("issuer", "arimelody.me")
|
||||
query.Set("algorithm", "SHA1")
|
||||
query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH))
|
||||
query.Set("period", fmt.Sprintf("%d", TIME_STEP))
|
||||
// query.Set("algorithm", "SHA1")
|
||||
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
|
||||
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
return url.String()
|
||||
|
@ -78,7 +78,7 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
|
|||
err := db.Select(
|
||||
&totps,
|
||||
"SELECT * FROM totp " +
|
||||
"WHERE account=$1 " +
|
||||
"WHERE account=$1 AND confirmed=true " +
|
||||
"ORDER BY created_at ASC",
|
||||
accountID,
|
||||
)
|
||||
|
@ -89,14 +89,36 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
|
|||
return totps, nil
|
||||
}
|
||||
|
||||
func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) {
|
||||
totps, err := GetTOTPsForAccount(db, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, method := range totps {
|
||||
check := GenerateTOTP(method.Secret, 0)
|
||||
if check == totp {
|
||||
return &method, nil
|
||||
}
|
||||
// try again with offset- maybe user input the code late?
|
||||
check = GenerateTOTP(method.Secret, 1)
|
||||
if check == totp {
|
||||
return &method, nil
|
||||
}
|
||||
}
|
||||
// user failed all TOTP checks
|
||||
// note: this state will still occur even if the account has no TOTP methods.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
|
||||
totp := model.TOTP{}
|
||||
|
||||
err := db.Get(
|
||||
&totp,
|
||||
"SELECT * FROM totp " +
|
||||
"WHERE account=$1",
|
||||
"WHERE account=$1 AND name=$2",
|
||||
accountID,
|
||||
name,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
|
@ -108,6 +130,15 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
|
|||
return &totp, nil
|
||||
}
|
||||
|
||||
func ConfirmTOTP(db *sqlx.DB, accountID string, name string) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE totp SET confirmed=true WHERE account=$1 AND name=$2",
|
||||
accountID,
|
||||
name,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO totp (account, name, secret) " +
|
||||
|
@ -127,3 +158,8 @@ func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
|
|||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteUnconfirmedTOTPs(db *sqlx.DB) error {
|
||||
_, err := db.Exec("DELETE FROM totp WHERE confirmed=false")
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,38 +1,17 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
)
|
||||
|
||||
const API_ENDPOINT = "https://discord.com/api/v10"
|
||||
|
||||
var CREDENTIALS_PROVIDED = true
|
||||
var CLIENT_ID = func() string {
|
||||
id := global.Config.Discord.ClientID
|
||||
if id == "" {
|
||||
// fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided.\n")
|
||||
CREDENTIALS_PROVIDED = false
|
||||
}
|
||||
return id
|
||||
}()
|
||||
var CLIENT_SECRET = func() string {
|
||||
secret := global.Config.Discord.Secret
|
||||
if secret == "" {
|
||||
// fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided.\n")
|
||||
CREDENTIALS_PROVIDED = false
|
||||
}
|
||||
return secret
|
||||
}()
|
||||
var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.Config.BaseUrl)
|
||||
var REDIRECT_URI = fmt.Sprintf("https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify", CLIENT_ID, OAUTH_CALLBACK_URI)
|
||||
|
||||
type (
|
||||
AccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
|
@ -68,15 +47,15 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
func GetOAuthTokenFromCode(code string) (string, error) {
|
||||
func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) {
|
||||
// let's get an oauth token!
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT),
|
||||
strings.NewReader(url.Values{
|
||||
"client_id": {CLIENT_ID},
|
||||
"client_secret": {CLIENT_SECRET},
|
||||
"client_id": {app.Config.Discord.ClientID},
|
||||
"client_secret": {app.Config.Discord.Secret},
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {OAUTH_CALLBACK_URI},
|
||||
"redirect_uri": {GetOAuthCallbackURI(app.Config.BaseUrl)},
|
||||
}.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
|
@ -115,3 +94,15 @@ func GetDiscordUserFromAuth(token string) (DiscordUser, error) {
|
|||
|
||||
return auth_info.User, nil
|
||||
}
|
||||
|
||||
func GetOAuthCallbackURI(baseURL string) string {
|
||||
return fmt.Sprintf("%s/admin/login", baseURL)
|
||||
}
|
||||
|
||||
func GetRedirectURI(app *model.AppState) string {
|
||||
return fmt.Sprintf(
|
||||
"https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify",
|
||||
app.Config.Discord.ClientID,
|
||||
GetOAuthCallbackURI(app.Config.BaseUrl),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
package global
|
||||
|
||||
const COOKIE_TOKEN string = "AM_TOKEN"
|
101
global/funcs.go
101
global/funcs.go
|
@ -1,101 +0,0 @@
|
|||
package global
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"arimelody-web/colour"
|
||||
)
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
"your mother",
|
||||
"awesome powers beyond comprehension",
|
||||
"jared",
|
||||
"the weight of my sins",
|
||||
"the arc reactor",
|
||||
"AA batteries",
|
||||
"15 euro solar panel from ebay",
|
||||
"magnets, how do they work",
|
||||
"a fax machine",
|
||||
"dell optiplex",
|
||||
"a trans girl's nintendo wii",
|
||||
"BASS",
|
||||
"electricity, duh",
|
||||
"seven hamsters in a big wheel",
|
||||
"girls",
|
||||
"mzungu hosting",
|
||||
"golang",
|
||||
"the state of the world right now",
|
||||
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
|
||||
"the good folks at aperture science",
|
||||
"free2play CDs",
|
||||
"aridoodle",
|
||||
"the love of creating",
|
||||
"not for the sake of art; not for the sake of money; we like painting naked people",
|
||||
"30 billion dollars in VC funding",
|
||||
}
|
||||
|
||||
func DefaultHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Server", "arimelody.me")
|
||||
w.Header().Add("Do-Not-Stab", "1")
|
||||
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
w.Header().Add("X-Hacker", "spare me please")
|
||||
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
|
||||
w.Header().Add("X-Thinking-With", "Portals")
|
||||
w.Header().Add(
|
||||
"X-Powered-By",
|
||||
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type LoggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Status int
|
||||
}
|
||||
|
||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||
lrw.Status = status
|
||||
lrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func HTTPLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
lrw := LoggingResponseWriter{w, http.StatusOK}
|
||||
|
||||
next.ServeHTTP(&lrw, r)
|
||||
|
||||
after := time.Now()
|
||||
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
|
||||
elapsed := "<1"
|
||||
if difference >= 1 {
|
||||
elapsed = strconv.Itoa(difference)
|
||||
}
|
||||
|
||||
statusColour := colour.Reset
|
||||
|
||||
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
|
||||
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
|
||||
if lrw.Status - 400 <= 0 { statusColour = colour.White }
|
||||
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
|
||||
|
||||
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
|
||||
after.Format(time.UnixDate),
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
statusColour,
|
||||
lrw.Status,
|
||||
colour.Reset,
|
||||
elapsed,
|
||||
r.Header["User-Agent"][0])
|
||||
})
|
||||
}
|
6
go.mod
6
go.mod
|
@ -8,4 +8,8 @@ require (
|
|||
)
|
||||
|
||||
require golang.org/x/crypto v0.27.0 // indirect
|
||||
require github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
|
||||
require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
)
|
||||
|
|
6
go.sum
6
go.sum
|
@ -8,7 +8,9 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
|
|
142
log/log.go
Normal file
142
log/log.go
Normal file
|
@ -0,0 +1,142 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type (
|
||||
Logger struct {
|
||||
DB *sqlx.DB
|
||||
}
|
||||
|
||||
Log struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Level LogLevel `json:"level" db:"level"`
|
||||
Type string `json:"type" db:"type"`
|
||||
Content string `json:"content" db:"content"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
TYPE_ACCOUNT string = "account"
|
||||
TYPE_MUSIC string = "music"
|
||||
TYPE_ARTIST string = "artist"
|
||||
TYPE_BLOG string = "blog"
|
||||
TYPE_ARTWORK string = "artwork"
|
||||
TYPE_FILES string = "files"
|
||||
TYPE_MISC string = "misc"
|
||||
)
|
||||
|
||||
type LogLevel int
|
||||
const (
|
||||
LEVEL_INFO LogLevel = 0
|
||||
LEVEL_WARN LogLevel = 1
|
||||
)
|
||||
|
||||
const DEFAULT_LOG_PAGE_LENGTH = 25
|
||||
|
||||
func (self *Logger) Info(logType string, format string, args ...any) {
|
||||
logString := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString)
|
||||
err := createLog(self.DB, LEVEL_INFO, logType, logString)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Logger) Warn(logType string, format string, args ...any) {
|
||||
logString := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(os.Stderr, "[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString)
|
||||
err := createLog(self.DB, LEVEL_WARN, logType, logString)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Logger) Fetch(id string) (*Log, error) {
|
||||
log := Log{}
|
||||
err := self.DB.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
|
||||
return &log, err
|
||||
}
|
||||
|
||||
func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) {
|
||||
logs := []*Log{}
|
||||
|
||||
params := []any{ limit, offset }
|
||||
conditions := ""
|
||||
|
||||
if len(content) > 0 {
|
||||
content = "%" + content + "%"
|
||||
conditions += " WHERE content LIKE $3"
|
||||
params = append(params, content)
|
||||
}
|
||||
|
||||
if len(levelFilters) > 0 {
|
||||
if len(conditions) > 0 {
|
||||
conditions += " AND level IN ("
|
||||
} else {
|
||||
conditions += " WHERE level IN ("
|
||||
}
|
||||
for i := range levelFilters {
|
||||
conditions += fmt.Sprintf("$%d", len(params) + 1)
|
||||
if i < len(levelFilters) - 1 {
|
||||
conditions += ","
|
||||
}
|
||||
params = append(params, levelFilters[i])
|
||||
}
|
||||
conditions += ")"
|
||||
}
|
||||
|
||||
if len(typeFilters) > 0 {
|
||||
if len(conditions) > 0 {
|
||||
conditions += " AND type IN ("
|
||||
} else {
|
||||
conditions += " WHERE type IN ("
|
||||
}
|
||||
for i := range typeFilters {
|
||||
conditions += fmt.Sprintf("$%d", len(params) + 1)
|
||||
if i < len(typeFilters) - 1 {
|
||||
conditions += ","
|
||||
}
|
||||
params = append(params, typeFilters[i])
|
||||
}
|
||||
conditions += ")"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
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
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func createLog(db *sqlx.DB, logLevel LogLevel, logType string, content string) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
|
||||
logLevel,
|
||||
logType,
|
||||
content,
|
||||
)
|
||||
return err
|
||||
}
|
326
main.go
326
main.go
|
@ -3,23 +3,28 @@ package main
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
stdLog "log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/api"
|
||||
"arimelody-web/colour"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/view"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// used for database migrations
|
||||
|
@ -30,48 +35,50 @@ const DEFAULT_PORT int64 = 8080
|
|||
func main() {
|
||||
fmt.Printf("made with <3 by ari melody\n\n")
|
||||
|
||||
// TODO: refactor `global` to `AppState`
|
||||
// this should contain `Config` and `DB`, and be passed through to all
|
||||
// handlers that need it. it's better than weird static globals everywhere!
|
||||
app := model.AppState{
|
||||
Config: controller.GetConfig(),
|
||||
}
|
||||
|
||||
// initialise database connection
|
||||
if global.Config.DB.Host == "" {
|
||||
if app.Config.DB.Host == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.Name == "" {
|
||||
if app.Config.DB.Name == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.User == "" {
|
||||
if app.Config.DB.User == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.Pass == "" {
|
||||
if app.Config.DB.Pass == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var err error
|
||||
global.DB, err = sqlx.Connect(
|
||||
app.DB, err = sqlx.Connect(
|
||||
"postgres",
|
||||
fmt.Sprintf(
|
||||
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
|
||||
global.Config.DB.Host,
|
||||
global.Config.DB.Port,
|
||||
global.Config.DB.User,
|
||||
global.Config.DB.Name,
|
||||
global.Config.DB.Pass,
|
||||
app.Config.DB.Host,
|
||||
app.Config.DB.Port,
|
||||
app.Config.DB.User,
|
||||
app.Config.DB.Name,
|
||||
app.Config.DB.Pass,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
global.DB.SetConnMaxLifetime(time.Minute * 3)
|
||||
global.DB.SetMaxOpenConns(10)
|
||||
global.DB.SetMaxIdleConns(10)
|
||||
defer global.DB.Close()
|
||||
app.DB.SetConnMaxLifetime(time.Minute * 3)
|
||||
app.DB.SetMaxOpenConns(10)
|
||||
app.DB.SetMaxIdleConns(10)
|
||||
defer app.DB.Close()
|
||||
|
||||
app.Log = log.Logger{ DB: app.DB }
|
||||
|
||||
// handle command arguments
|
||||
if len(os.Args) > 1 {
|
||||
|
@ -85,31 +92,36 @@ func main() {
|
|||
}
|
||||
username := os.Args[2]
|
||||
totpName := os.Args[3]
|
||||
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
||||
|
||||
account, err := controller.GetAccount(global.DB, username)
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
||||
totp := model.TOTP {
|
||||
AccountID: account.ID,
|
||||
Name: totpName,
|
||||
Secret: string(secret),
|
||||
}
|
||||
|
||||
err = controller.CreateTOTP(global.DB, &totp)
|
||||
err = controller.CreateTOTP(app.DB, &totp)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
|
||||
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
|
||||
fmt.Printf("%s\n", url)
|
||||
return
|
||||
|
@ -122,23 +134,24 @@ func main() {
|
|||
username := os.Args[2]
|
||||
totpName := os.Args[3]
|
||||
|
||||
account, err := controller.GetAccount(global.DB, username)
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = controller.DeleteTOTP(global.DB, account.ID, totpName)
|
||||
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
|
||||
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
|
||||
return
|
||||
|
||||
|
@ -149,20 +162,20 @@ func main() {
|
|||
}
|
||||
username := os.Args[2]
|
||||
|
||||
account, err := controller.GetAccount(global.DB, username)
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(global.DB, account.ID)
|
||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
@ -182,62 +195,80 @@ func main() {
|
|||
username := os.Args[2]
|
||||
totpName := os.Args[3]
|
||||
|
||||
account, err := controller.GetAccount(global.DB, username)
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totp, err := controller.GetTOTP(global.DB, account.ID, totpName)
|
||||
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if totp == nil {
|
||||
fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := controller.GenerateTOTP(totp.Secret, 0)
|
||||
fmt.Printf("%s\n", code)
|
||||
return
|
||||
|
||||
case "cleanTOTP":
|
||||
err := controller.DeleteUnconfirmedTOTPs(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
|
||||
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
|
||||
return
|
||||
|
||||
case "createInvite":
|
||||
fmt.Printf("Creating invite...\n")
|
||||
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
|
||||
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code)
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
|
||||
fmt.Printf(
|
||||
"Here you go! This code expires in %d hours: %s\n",
|
||||
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
|
||||
invite.Code,
|
||||
)
|
||||
return
|
||||
|
||||
case "purgeInvites":
|
||||
fmt.Printf("Deleting all invites...\n")
|
||||
err := controller.DeleteAllInvites(global.DB)
|
||||
err := controller.DeleteAllInvites(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
|
||||
fmt.Printf("Invites deleted successfully.\n")
|
||||
return
|
||||
|
||||
case "listAccounts":
|
||||
accounts, err := controller.GetAllAccounts(global.DB)
|
||||
accounts, err := controller.GetAllAccounts(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
email := "<none>"
|
||||
if account.Email.Valid { email = account.Email.String }
|
||||
fmt.Printf(
|
||||
"User: %s\n" +
|
||||
"\tID: %s\n" +
|
||||
|
@ -245,12 +276,46 @@ func main() {
|
|||
"\tCreated: %s\n",
|
||||
account.Username,
|
||||
account.ID,
|
||||
account.Email,
|
||||
email,
|
||||
account.CreatedAt,
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
case "changePassword":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
username := os.Args[2]
|
||||
password := os.Args[3]
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
account.Password = string(hashedPassword)
|
||||
err = controller.UpdateAccount(app.DB, account)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
|
||||
fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username)
|
||||
return
|
||||
|
||||
case "deleteAccount":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
|
||||
|
@ -259,14 +324,14 @@ func main() {
|
|||
username := os.Args[2]
|
||||
fmt.Printf("Deleting account \"%s\"...\n", username)
|
||||
|
||||
account, err := controller.GetAccount(global.DB, username)
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
@ -277,15 +342,36 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteAccount(global.DB, username)
|
||||
err = controller.DeleteAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
|
||||
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
||||
return
|
||||
|
||||
|
||||
case "logs":
|
||||
// TODO: add log search parameters
|
||||
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, item := range(logs) {
|
||||
levelStr := ""
|
||||
switch item.Level {
|
||||
case log.LEVEL_INFO:
|
||||
levelStr = "INFO"
|
||||
case log.LEVEL_WARN:
|
||||
levelStr = "WARN"
|
||||
default:
|
||||
levelStr = fmt.Sprintf("? (%d)", item.Level)
|
||||
}
|
||||
fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// command help
|
||||
|
@ -295,6 +381,7 @@ func main() {
|
|||
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
|
||||
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
|
||||
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
|
||||
"cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\n" +
|
||||
"\n" +
|
||||
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
||||
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
||||
|
@ -305,20 +392,20 @@ func main() {
|
|||
}
|
||||
|
||||
// handle DB migrations
|
||||
controller.CheckDBVersionAndMigrate(global.DB)
|
||||
controller.CheckDBVersionAndMigrate(app.DB)
|
||||
|
||||
// initial invite code
|
||||
accountsCount := 0
|
||||
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
if err != nil { panic(err) }
|
||||
if accountsCount == 0 {
|
||||
_, err := global.DB.Exec("DELETE FROM invite")
|
||||
_, err := app.DB.Exec("DELETE FROM invite")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
|
||||
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
@ -328,28 +415,35 @@ func main() {
|
|||
}
|
||||
|
||||
// delete expired invites
|
||||
err = controller.DeleteExpiredInvites(global.DB)
|
||||
err = controller.DeleteExpiredInvites(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// clean up unconfirmed TOTP methods
|
||||
err = controller.DeleteUnconfirmedTOTPs(app.DB)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// start the web server!
|
||||
mux := createServeMux()
|
||||
fmt.Printf("Now serving at %s:%d\n", global.Config.BaseUrl, global.Config.Port)
|
||||
log.Fatal(
|
||||
http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port),
|
||||
global.HTTPLog(global.DefaultHeaders(mux)),
|
||||
mux := createServeMux(&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),
|
||||
HTTPLog(DefaultHeaders(mux)),
|
||||
))
|
||||
}
|
||||
|
||||
func createServeMux() *http.ServeMux {
|
||||
func createServeMux(app *model.AppState) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(global.DB)))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(global.DB)))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(global.DB)))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads"))))
|
||||
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", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
@ -357,7 +451,7 @@ func createServeMux() *http.ServeMux {
|
|||
}
|
||||
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
err := templates.Pages["index"].Execute(w, nil)
|
||||
err := templates.IndexTemplate.Execute(w, nil)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -390,3 +484,93 @@ func staticHandler(directory string) http.Handler {
|
|||
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
"your mother",
|
||||
"awesome powers beyond comprehension",
|
||||
"jared",
|
||||
"the weight of my sins",
|
||||
"the arc reactor",
|
||||
"AA batteries",
|
||||
"15 euro solar panel from ebay",
|
||||
"magnets, how do they work",
|
||||
"a fax machine",
|
||||
"dell optiplex",
|
||||
"a trans girl's nintendo wii",
|
||||
"BASS",
|
||||
"electricity, duh",
|
||||
"seven hamsters in a big wheel",
|
||||
"girls",
|
||||
"mzungu hosting",
|
||||
"golang",
|
||||
"the state of the world right now",
|
||||
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
|
||||
"the good folks at aperture science",
|
||||
"free2play CDs",
|
||||
"aridoodle",
|
||||
"the love of creating",
|
||||
"not for the sake of art; not for the sake of money; we like painting naked people",
|
||||
"30 billion dollars in VC funding",
|
||||
}
|
||||
|
||||
func DefaultHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Server", "arimelody.me")
|
||||
w.Header().Add("Do-Not-Stab", "1")
|
||||
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
w.Header().Add("X-Hacker", "spare me please")
|
||||
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
|
||||
w.Header().Add("X-Thinking-With", "Portals")
|
||||
w.Header().Add(
|
||||
"X-Powered-By",
|
||||
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type LoggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Status int
|
||||
}
|
||||
|
||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||
lrw.Status = status
|
||||
lrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func HTTPLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
lrw := LoggingResponseWriter{w, http.StatusOK}
|
||||
|
||||
next.ServeHTTP(&lrw, r)
|
||||
|
||||
after := time.Now()
|
||||
difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
|
||||
elapsed := "<1"
|
||||
if difference >= 1 {
|
||||
elapsed = strconv.Itoa(difference)
|
||||
}
|
||||
|
||||
statusColour := colour.Reset
|
||||
|
||||
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
|
||||
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
|
||||
if lrw.Status - 400 <= 0 { statusColour = colour.White }
|
||||
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
|
||||
|
||||
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
|
||||
after.Format(time.UnixDate),
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
statusColour,
|
||||
lrw.Status,
|
||||
colour.Reset,
|
||||
elapsed,
|
||||
r.Header["User-Agent"][0])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const COOKIE_TOKEN string = "AM_SESSION"
|
||||
|
||||
type (
|
||||
Account struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
Password string `json:"password" db:"password"`
|
||||
Email string `json:"email" db:"email"`
|
||||
AvatarURL string `json:"avatar_url" db:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
Password string `json:"password" db:"password"`
|
||||
Email sql.NullString `json:"email" db:"email"`
|
||||
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
Privileges []AccountPrivilege `json:"privileges"`
|
||||
}
|
||||
|
|
39
model/appstate.go
Normal file
39
model/appstate.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"arimelody-web/log"
|
||||
)
|
||||
|
||||
type (
|
||||
DBConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
Name string `toml:"name"`
|
||||
User string `toml:"user"`
|
||||
Pass string `toml:"pass"`
|
||||
}
|
||||
|
||||
DiscordConfig struct {
|
||||
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
|
||||
ClientID string `toml:"client_id"`
|
||||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
Config struct {
|
||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
DataDirectory string `toml:"data_dir"`
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
DB DBConfig `toml:"db"`
|
||||
Discord DiscordConfig `toml:"discord"`
|
||||
}
|
||||
|
||||
AppState struct {
|
||||
DB *sqlx.DB
|
||||
Config Config
|
||||
Log log.Logger
|
||||
}
|
||||
)
|
|
@ -24,6 +24,7 @@ type (
|
|||
Tracks []*Track `json:"tracks"`
|
||||
Credits []*Credit `json:"credits"`
|
||||
Links []*Link `json:"links"`
|
||||
CreatedAt time.Time `json:"-" db:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
18
model/session.go
Normal file
18
model/session.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"-" db:"token"`
|
||||
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
ExpiresAt time.Time `json:"-" db:"expires_at"`
|
||||
|
||||
Account *Account `json:"-" db:"-"`
|
||||
AttemptAccount *Account `json:"-" db:"-"`
|
||||
Message sql.NullString `json:"-" db:"message"`
|
||||
Error sql.NullString `json:"-" db:"error"`
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Token struct {
|
||||
Token string `json:"token" db:"token"`
|
||||
AccountID string `json:"-" db:"account"`
|
||||
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
}
|
|
@ -9,4 +9,5 @@ type TOTP struct {
|
|||
AccountID string `json:"accountID" db:"account"`
|
||||
Secret string `json:"-" db:"secret"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
Confirmed bool `json:"-" db:"confirmed"`
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ type (
|
|||
Description string `json:"description"`
|
||||
Lyrics string `json:"lyrics" db:"lyrics"`
|
||||
PreviewURL string `json:"previewURL" db:"preview_url"`
|
||||
|
||||
Number int
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
|
||||
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJMEExYKADsWIQTu
|
||||
jeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbAwULCQgHAgIiAgYVCgkICwIEFgID
|
||||
AQIeBwIXgAAKCRDPmYKckmeBiGCbAP4wTcLCU5ZlfSTJrFtGhQKWA6DxtUO7Cegk
|
||||
Vu8SgkY3KgEA1/YqjZ1vSaqPDN4137vmhkhfduoYOjN0iptNj39u2wG4OARk1bTd
|
||||
EgorBgEEAZdVAQUBAQdAnA2drPzQBoXNdwIrFnovuF0CjX+8+8QSugCF4a5ZEXED
|
||||
AQgHiHgEGBYKACAWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbDAAKCRDP
|
||||
mYKckmeBiC/xAQD1hu4WcstR40lkUxMqhZ44wmizrDA+eGCdh7Ge3Gy79wEAx385
|
||||
GnYoNplMTA4BTGs7orV4WSfSkoBx0+px1UOewgs=
|
||||
=M1Bp
|
||||
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF
|
||||
CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB
|
||||
iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ
|
||||
+oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI
|
||||
kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC
|
||||
BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF
|
||||
ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P
|
||||
f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL
|
||||
d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq
|
||||
VgUJBaOeTACBdiAEGRYKAB0WIQQlu5dWmBR/P3ZxngxgtfA4bj3bfgUCZ7UmigAK
|
||||
CRBgtfA4bj3bfux+AP4y5ydrjnGBMX7GuB2nh55SRdscSiXsZ66ntnjXyQcbWgEA
|
||||
pDuu7FqXzXcnluuZxNFDT740Rnzs60tTeplDqGGWcAQJEM+ZgpySZ4GIc0kA/iSw
|
||||
Nw+r3FC75omwrPpJF13B5fq93FweFx+oSaES6qzkAQDvgCK77qKKbvCju0g8zSsK
|
||||
EZnv6xR4uvtGdVkvLpBdC7gzBGe1JpkWCSsGAQQB2kcPAQEHQGnU4lXFLchhKYkC
|
||||
PshP+jvuRsNoedaDOK2p4dkQC8JuiH4EGBYKACYCGyAWIQTujeuNYocuegkeKt/P
|
||||
mYKckmeBiAUCZ7UqXgUJBaOeRQAKCRDPmYKckmeBiL9KAQCJZIBhuSsoYa61I0XZ
|
||||
cKzGZbB0h9pD6eg1VRswNIgHtQEAwu9Hgs1rs9cySvKbO7WgK6Qh6EfrvGgGOXCO
|
||||
m3wVsg24OARntSo5EgorBgEEAZdVAQUBAQdA+/k586W1OHxndzDJNpbd+wqjyjr0
|
||||
D5IXxfDs00advB0DAQgHiH4EGBYKACYWIQTujeuNYocuegkeKt/PmYKckmeBiAUC
|
||||
Z7UqOQIbDAUJBaOagAAKCRDPmYKckmeBiEFxAQCgziQt2l3u7jnZVij4zop+K2Lv
|
||||
TVFtkbG61tf6brRzBgD/X6c6X5BRyQC51JV1I1RFRBdeMAIXzcLFg2v3WUMccQs=
|
||||
=YmHI
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
|
|
|
@ -1,42 +1,33 @@
|
|||
function toggle_config_setting(config, name) {
|
||||
if (config[name]) {
|
||||
delete config[name];
|
||||
update_config(config);
|
||||
return true;
|
||||
}
|
||||
config[name] = true;
|
||||
update_config(config);
|
||||
return true;
|
||||
}
|
||||
const DEFAULT_CONFIG = {
|
||||
crt: false
|
||||
};
|
||||
const config = (() => {
|
||||
let saved = localStorage.getItem("config");
|
||||
if (saved) {
|
||||
const config = JSON.parse(saved);
|
||||
setCRT(config.crt || DEFAULT_CONFIG.crt);
|
||||
return config;
|
||||
}
|
||||
|
||||
function set_config_setting(config, name, value) {
|
||||
config[name] = value;
|
||||
update_config(config);
|
||||
return true;
|
||||
}
|
||||
localStorage.setItem("config", JSON.stringify(DEFAULT_CONFIG));
|
||||
return DEFAULT_CONFIG;
|
||||
})();
|
||||
|
||||
function clear_config_setting(config, name) {
|
||||
if (!config[name]) return false;
|
||||
delete config[name];
|
||||
update_config(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
function update_config(config) {
|
||||
localStorage.setItem("config", JSON.stringify(config));
|
||||
}
|
||||
|
||||
const config = JSON.parse(localStorage.getItem("config")) || {};
|
||||
if (config) {
|
||||
if (config.disable_crt) {
|
||||
document.querySelector('div#overlay').setAttribute("hidden", true);
|
||||
document.body.style.textShadow = "none";
|
||||
document.getElementById('toggle-crt').classList.add("disabled");
|
||||
}
|
||||
function saveConfig() {
|
||||
localStorage.setItem("config", JSON.stringify(config));
|
||||
}
|
||||
|
||||
document.getElementById("toggle-crt").addEventListener("click", () => {
|
||||
toggle_config_setting(config, "disable_crt");
|
||||
document.querySelector('div#overlay').toggleAttribute("hidden");
|
||||
document.getElementById('toggle-crt').className = config.disable_crt ? "disabled" : "";
|
||||
config.crt = !config.crt;
|
||||
setCRT(config.crt);
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
function setCRT(/** @type boolean */ enabled) {
|
||||
if (enabled) {
|
||||
document.body.classList.add("crt");
|
||||
} else {
|
||||
document.body.classList.remove("crt");
|
||||
}
|
||||
document.getElementById('toggle-crt').className = enabled ? "" : "disabled";
|
||||
}
|
||||
|
|
16
public/script/index.js
Normal file
16
public/script/index.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
const hexPrimary = document.getElementById("hex-primary");
|
||||
const hexSecondary = document.getElementById("hex-secondary");
|
||||
const hexTertiary = document.getElementById("hex-tertiary");
|
||||
|
||||
function updateHexColours() {
|
||||
const style = getComputedStyle(document.body);
|
||||
hexPrimary.textContent = style.getPropertyValue('--primary');
|
||||
hexSecondary.textContent = style.getPropertyValue('--secondary');
|
||||
hexTertiary.textContent = style.getPropertyValue('--tertiary');
|
||||
}
|
||||
|
||||
updateHexColours();
|
||||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
updateHexColours();
|
||||
});
|
|
@ -49,7 +49,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
.filter((e) => e.innerText != "")
|
||||
.forEach((e) => {
|
||||
type_out(e);
|
||||
console.log(e);
|
||||
});
|
||||
[...document.querySelectorAll("ol, ul")]
|
||||
.filter((e) => e.innerText != "")
|
||||
|
|
|
@ -1,66 +1,74 @@
|
|||
/**
|
||||
* 🏳️🌈🏳️⚧️💖 pride flag 💖🏳️⚧️🏳️🌈
|
||||
* made with ❤️ by ari melody, 2023
|
||||
*
|
||||
* web: https://arimelody.me
|
||||
* source: https://github.com/mellodoot/prideflag
|
||||
*/
|
||||
//
|
||||
// pride flag - copyright (c) 2024 ari melody
|
||||
//
|
||||
// this code is provided AS-IS, WITHOUT ANY WARRANTY, to be
|
||||
// freely redistributed and/or modified as you please, however
|
||||
// retaining this license in any redistribution.
|
||||
//
|
||||
// please use this flag to link to an LGBTQI+-supporting page
|
||||
// of your choosing!
|
||||
//
|
||||
// web: https://arimelody.me
|
||||
// source: https://git.arimelody.me/ari/prideflag
|
||||
//
|
||||
|
||||
const pride_url = "https://git.arimelody.me/ari/prideflag";
|
||||
|
||||
const pride_flag_svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
||||
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
||||
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
||||
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
||||
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
||||
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
||||
|
||||
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
||||
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
||||
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
||||
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
||||
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
||||
|
||||
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
||||
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
||||
</svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
||||
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
||||
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
||||
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
||||
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
||||
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
||||
|
||||
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
||||
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
||||
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
||||
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
||||
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
||||
|
||||
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
||||
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
||||
</svg>`;
|
||||
|
||||
const pride_flag_css =
|
||||
`#pride-flag svg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 120px;
|
||||
transform-origin: 100% 0%;
|
||||
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
|
||||
z-index: 8008135;
|
||||
pointer-events: none;
|
||||
}
|
||||
#pride-flag svg:hover {
|
||||
transform: scale(110%);
|
||||
}
|
||||
#pride-flag svg:active {
|
||||
transform: scale(110%);
|
||||
}
|
||||
#pride-flag svg * {
|
||||
pointer-events: all;
|
||||
}`;
|
||||
`#prideflag {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 120px;
|
||||
transform-origin: 100% 0%;
|
||||
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
|
||||
z-index: 8008135;
|
||||
pointer-events: none;
|
||||
}
|
||||
#prideflag:hover {
|
||||
transform: scale(110%);
|
||||
}
|
||||
#prideflag:active {
|
||||
transform: scale(110%);
|
||||
}
|
||||
#prideflag * {
|
||||
pointer-events: all;
|
||||
}`;
|
||||
|
||||
function create_pride_flag() {
|
||||
const container = document.createElement("a");
|
||||
container.id = "pride-flag";
|
||||
container.href = "https://github.com/mellodoot/prideflag";
|
||||
container.target = "_blank";
|
||||
container.innerHTML = pride_flag_svg;
|
||||
return container;
|
||||
const flag = document.createElement("a");
|
||||
flag.id = "prideflag";
|
||||
flag.href = pride_url;
|
||||
flag.target = "_blank";
|
||||
flag.innerHTML = pride_flag_svg;
|
||||
return flag;
|
||||
}
|
||||
|
||||
function load_pride_flag_style() {
|
||||
const pride_stylesheet = document.createElement('style');
|
||||
pride_stylesheet.textContent = pride_flag_css;
|
||||
document.head.appendChild(pride_stylesheet);
|
||||
const pride_stylesheet = document.createElement('style');
|
||||
pride_stylesheet.textContent = pride_flag_css;
|
||||
document.head.appendChild(pride_stylesheet);
|
||||
}
|
||||
|
||||
load_pride_flag_style();
|
||||
pride_flag = create_pride_flag();
|
||||
document.querySelector("main").appendChild(pride_flag);
|
||||
flag = create_pride_flag();
|
||||
document.body.appendChild(flag);
|
||||
|
|
|
@ -1,19 +1,34 @@
|
|||
:root {
|
||||
--primary: #b7fd49;
|
||||
--secondary: #f8e05b;
|
||||
--tertiary: #f788fe;
|
||||
--links: #5eb2ff;
|
||||
--background: #080808;
|
||||
--on-background: #f0f0f0;
|
||||
|
||||
--primary: #b7fd49;
|
||||
--secondary: #f8e05b;
|
||||
--tertiary: #f788fe;
|
||||
--links: #5eb2ff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--on-background: #101010;
|
||||
|
||||
--primary: #6d9e23;
|
||||
--secondary: #a5911e;
|
||||
--tertiary: #a92cb1;
|
||||
--links: #3ba1ff;
|
||||
}
|
||||
}
|
||||
|
||||
.col-primary {
|
||||
color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.col-secondary {
|
||||
color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.col-tertiary {
|
||||
color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
footer {
|
||||
border-top: 1px solid #888;
|
||||
border-top: 1px solid #8888;
|
||||
}
|
||||
|
||||
#footer {
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
margin: auto;
|
||||
padding: 2rem 0;
|
||||
color: #aaa;
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
margin: auto;
|
||||
padding: 2rem 0;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,187 +1,189 @@
|
|||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
border-bottom: 1px solid #888;
|
||||
background-color: #080808;
|
||||
z-index: 1;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
border-bottom: 1px solid #8888;
|
||||
background-color: var(--background);
|
||||
z-index: 1;
|
||||
|
||||
transition: color .2s, background-color .2s;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
height: 3em;
|
||||
margin: auto;
|
||||
padding: 0 1em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: .8em;
|
||||
align-items: center;
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
height: 3em;
|
||||
margin: auto;
|
||||
padding: 0 1em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: .8em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#header-home {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img#header-icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
margin: .5em;
|
||||
display: block;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
margin: .5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#header-text {
|
||||
width: 11em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
width: 11em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#header-text h1 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#header-text h2 {
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
margin: 0;
|
||||
font-size: .7em;
|
||||
color: #bbb;
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
margin: 0;
|
||||
font-size: .7em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
#header-links-toggle {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color .2s;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color .2s;
|
||||
}
|
||||
|
||||
#header-links-toggle:hover {
|
||||
background-color: #fff2;
|
||||
background-color: #fff2;
|
||||
}
|
||||
|
||||
header ul#header-links {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: .5em;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: .5em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header ul li {
|
||||
list-style: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
header ul li a,
|
||||
header ul li span {
|
||||
padding: .4em .5em;
|
||||
border: 1px solid var(--links);
|
||||
color: var(--links);
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
transition-property: color, border-color, background-color;
|
||||
transition-duration: .2s;
|
||||
animation-delay: 0s;
|
||||
animation: list-item-fadein .2s forwards;
|
||||
opacity: 0;
|
||||
text-decoration: none;
|
||||
padding: .4em .5em;
|
||||
border: 1px solid var(--links);
|
||||
color: var(--links);
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
transition-property: color, border-color, background-color;
|
||||
transition-duration: .2s;
|
||||
animation-delay: 0s;
|
||||
animation: list-item-fadein .2s forwards;
|
||||
opacity: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header ul li span {
|
||||
color: #aaa;
|
||||
border-color: #aaa;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
color: #aaa;
|
||||
border-color: #aaa;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header ul li a:hover {
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
background-color: var(--links) !important;
|
||||
text-decoration: none;
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
background-color: var(--links) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
#toggle-crt a {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
opacity: 1;
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#toggle-crt a:hover {
|
||||
color: #111;
|
||||
background-color: var(--primary) !important;
|
||||
color: #111;
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
#toggle-crt.disabled a {
|
||||
opacity: .5 !important;
|
||||
opacity: .5 !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 780px) {
|
||||
header {
|
||||
font-size: 14px;
|
||||
}
|
||||
header {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: calc(100vw - 2rem);
|
||||
margin: 0;
|
||||
}
|
||||
nav {
|
||||
width: calc(100vw - 2rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div#header-text {
|
||||
flex-grow: 1;
|
||||
}
|
||||
div#header-text {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
a#header-links-toggle {
|
||||
display: flex;
|
||||
}
|
||||
a#header-links-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
header ul#header-links {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 2.7rem;
|
||||
width: calc(100vw - 2rem);
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid #888;
|
||||
background: #080808;
|
||||
display: none;
|
||||
}
|
||||
header ul#header-links {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 2.7rem;
|
||||
width: calc(100vw - 2rem);
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid #888;
|
||||
background: #080808;
|
||||
display: none;
|
||||
}
|
||||
|
||||
header ul#header-links.open {
|
||||
display: flex;
|
||||
}
|
||||
header ul#header-links.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
ul#header-links li {
|
||||
width: 100%;
|
||||
}
|
||||
ul#header-links li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul#header-links li a,
|
||||
ul#header-links li span {
|
||||
margin: 0;
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
ul#header-links li a,
|
||||
ul#header-links li span {
|
||||
margin: 0;
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes list-item-fadein {
|
||||
from {
|
||||
opacity: 1;
|
||||
background: #fff8;
|
||||
}
|
||||
from {
|
||||
opacity: 1;
|
||||
background: #fff8;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,43 +1,43 @@
|
|||
main {
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
min-height: calc(100vh - 10.3rem);
|
||||
margin: 0 auto 2rem auto;
|
||||
padding-top: 4rem;
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
min-height: calc(100vh - 10.3rem);
|
||||
margin: 0 auto 2rem auto;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
main h1 {
|
||||
line-height: 3rem;
|
||||
color: var(--primary);
|
||||
line-height: 3rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
main h2 {
|
||||
color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
main h3 {
|
||||
color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
div#me_irl {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
border: 2px solid white;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
div#me_irl img {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
div#me_irl::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
transform: translate(2px, 2px);
|
||||
background-image: linear-gradient(to top right,
|
||||
var(--primary),
|
||||
var(--secondary));
|
||||
z-index: -1;
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
transform: translate(2px, 2px);
|
||||
background-image: linear-gradient(to top right,
|
||||
var(--primary),
|
||||
var(--secondary));
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
h1,
|
||||
|
@ -49,7 +49,7 @@ h6,
|
|||
p,
|
||||
small,
|
||||
blockquote {
|
||||
transition: background-color 0.1s;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
h1 a,
|
||||
|
@ -58,7 +58,7 @@ h3 a,
|
|||
h4 a,
|
||||
h5 a,
|
||||
h6 a {
|
||||
color: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1 a:hover,
|
||||
|
@ -67,7 +67,7 @@ h3 a:hover,
|
|||
h4 a:hover,
|
||||
h5 a:hover,
|
||||
h6 a:hover {
|
||||
text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main h1:hover,
|
||||
|
@ -79,72 +79,72 @@ main h6:hover,
|
|||
main p:hover,
|
||||
main small:hover,
|
||||
main blockquote:hover {
|
||||
background-color: #fff1;
|
||||
background-color: #fff1;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0 2.5rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0 2.5rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
text-align: center;
|
||||
line-height: 0px;
|
||||
border-width: 1px 0 0 0;
|
||||
border-color: #888f;
|
||||
margin: 1.5em 0;
|
||||
overflow: visible;
|
||||
text-align: center;
|
||||
line-height: 0px;
|
||||
border-width: 1px 0 0 0;
|
||||
border-color: #888;
|
||||
margin: 1.5em 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
ul.links {
|
||||
display: flex;
|
||||
gap: 1em .5em;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
gap: 1em .5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
ul.links li {
|
||||
list-style: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul.links li a {
|
||||
padding: .4em .5em;
|
||||
border: 1px solid var(--links);
|
||||
color: var(--links);
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
transition-property: color, border-color, background-color;
|
||||
transition-duration: .2s;
|
||||
animation-delay: 0s;
|
||||
animation: list-item-fadein .2s forwards;
|
||||
opacity: 0;
|
||||
padding: .4em .5em;
|
||||
border: 1px solid var(--links);
|
||||
color: var(--links);
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
transition-property: color, border-color, background-color;
|
||||
transition-duration: .2s;
|
||||
animation-delay: 0s;
|
||||
animation: list-item-fadein .2s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
ul.links li a:hover {
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
background-color: var(--links) !important;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 1em var(--links);
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
background-color: var(--links) !important;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 1em var(--links);
|
||||
}
|
||||
|
||||
div#web-buttons {
|
||||
margin: 2rem 0;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
#web-buttons a {
|
||||
text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#web-buttons img {
|
||||
image-rendering: auto;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: auto;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
#web-buttons img:hover {
|
||||
margin: -1px;
|
||||
border: 1px solid #eee;
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
|
||||
margin: -1px;
|
||||
border: 1px solid #eee;
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,15 +14,17 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #080808;
|
||||
color: #eee;
|
||||
background: var(--background);
|
||||
color: var(--on-background);
|
||||
font-family: "Monaspace Argon", monospace;
|
||||
font-size: 18px;
|
||||
text-shadow: 0 0 3em;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
transition: color .2s, background-color .2s;
|
||||
}
|
||||
|
||||
main {
|
||||
body.crt #overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -118,6 +120,7 @@ a#backtotop:hover {
|
|||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: none;
|
||||
background-image: linear-gradient(180deg, rgba(0,0,0,0) 15%, rgb(0, 0, 0) 40%, rgb(0, 0, 0) 60%, rgba(0,0,0,0) 85%);
|
||||
background-size: 100vw .2em;
|
||||
background-repeat: repeat;
|
||||
|
@ -136,3 +139,27 @@ a#backtotop:hover {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
a.link-button:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes list-item-fadein {
|
||||
from {
|
||||
opacity: 1;
|
||||
background: var(--links);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body.crt {
|
||||
text-shadow: 0 0 3em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,10 @@ body {
|
|||
font-family: "Monaspace Argon", monospace;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
#background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -258,7 +262,7 @@ div#info p {
|
|||
|
||||
#title,
|
||||
#artist {
|
||||
text-shadow: 0 .05em 2px #0004
|
||||
text-shadow: 0 .05em 2px #0004;
|
||||
}
|
||||
|
||||
#type {
|
||||
|
@ -609,6 +613,10 @@ footer a:hover {
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#tracks h2 {
|
||||
margin: 0 auto .8em auto;
|
||||
}
|
||||
|
||||
#lyrics p.album-track-subheading {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
|
|
@ -1,146 +1,129 @@
|
|||
main {
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
min-height: calc(100vh - 10.3rem);
|
||||
margin: 0 auto 2rem auto;
|
||||
padding-top: 4rem;
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
min-height: calc(100vh - 10.3rem);
|
||||
margin: 0 auto 2rem auto;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
main nav {
|
||||
margin: -1rem .5rem 1rem .5rem;
|
||||
margin: -1rem .5rem 1rem .5rem;
|
||||
}
|
||||
|
||||
div.music {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5em;
|
||||
border: 1px solid #222;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff08;
|
||||
transition: background-color .1s;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5em;
|
||||
border: 1px solid #8882;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff08;
|
||||
transition: background-color .1s;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.music:hover {
|
||||
background-color: #fff1;
|
||||
background-color: #fff1;
|
||||
}
|
||||
|
||||
div.music a {
|
||||
text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.music h1:hover,
|
||||
.music h2:hover,
|
||||
.music h3:hover {
|
||||
background: initial;
|
||||
background: initial;
|
||||
}
|
||||
|
||||
.music-artwork img {
|
||||
border: 1px solid #888;
|
||||
border: 1px solid #8888;
|
||||
}
|
||||
|
||||
.music-title {
|
||||
margin: 0;
|
||||
color: #eee;
|
||||
font-size: 1.6em;
|
||||
line-height: 1.6em;
|
||||
margin: 0;
|
||||
color: var(--on-background);
|
||||
font-size: 1.6em;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
.music-title a {
|
||||
color: inherit;
|
||||
transition: color .2s;
|
||||
}
|
||||
|
||||
.music-year {
|
||||
color: #888;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.music-artist {
|
||||
margin: -.5rem 0 0 0;
|
||||
font-size: 1em;
|
||||
color: #aaa;
|
||||
margin: -.5rem 0 0 0;
|
||||
font-size: 1em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
h3[class^=music-type] {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: .8em;
|
||||
color: #eee;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: .8em;
|
||||
color: #eee;
|
||||
transition: color .2s;
|
||||
}
|
||||
|
||||
h3.music-type-single {
|
||||
color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
h3.music-type-compilation {
|
||||
color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
h3.music-type-album {
|
||||
color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
h3.music-type-upcoming {
|
||||
color: #f47070;
|
||||
color: #f47070;
|
||||
}
|
||||
|
||||
.music-links {
|
||||
width: fit-content;
|
||||
margin: .5em 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.7em;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
margin: .5em 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.7em;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.music-links li {
|
||||
list-style: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/*
|
||||
.music-links li a {
|
||||
padding: .2em .5em;
|
||||
border: 1px solid #65b4fd;
|
||||
color: #65b4fd;
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
transition-property: color, border-color, background-color;
|
||||
transition-duration: .2s;
|
||||
animation: list-item-fadein .2s forwards;
|
||||
animation-delay: 0s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.music-links li a:hover {
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
background-color: var(--links) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
*/
|
||||
|
||||
h2.question {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.answer {
|
||||
margin: -1rem 0 1rem 0;
|
||||
padding: .5em 1.5em;
|
||||
border-radius: 4px;
|
||||
margin: -1rem 0 1rem 0;
|
||||
padding: .5em 1.5em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 740px) {
|
||||
div.music {
|
||||
flex-direction: column;
|
||||
}
|
||||
div.music {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.music-artwork,
|
||||
.music-details {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.music-artwork,
|
||||
.music-details {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,51 +1,62 @@
|
|||
CREATE SCHEMA IF NOT EXISTS arimelody;
|
||||
|
||||
--
|
||||
-- Tables
|
||||
--
|
||||
|
||||
-- Audit logs
|
||||
CREATE TABLE arimelody.auditlog (
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
level int NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
-- Accounts
|
||||
CREATE TABLE arimelody.account (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
email text,
|
||||
avatar_url text,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
||||
|
||||
-- Privilege
|
||||
CREATE TABLE arimelody.privilege (
|
||||
account uuid NOT NULL,
|
||||
privilege text NOT NULL
|
||||
account UUID NOT NULL,
|
||||
privilege TEXT NOT NULL
|
||||
);
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
|
||||
|
||||
-- Invites
|
||||
CREATE TABLE arimelody.invite (
|
||||
code text NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
|
||||
|
||||
-- Tokens
|
||||
CREATE TABLE arimelody.token (
|
||||
-- Sessions
|
||||
CREATE TABLE arimelody.session (
|
||||
token TEXT,
|
||||
account UUID NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
expires_at TIMESTAMP DEFAULT NULL
|
||||
expires_at TIMESTAMP DEFAULT NULL,
|
||||
account UUID,
|
||||
attempt_account UUID,
|
||||
message TEXT,
|
||||
error TEXT
|
||||
);
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
|
||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
|
||||
|
||||
-- TOTPs
|
||||
-- TOTP methods
|
||||
CREATE TABLE arimelody.totp (
|
||||
name TEXT NOT NULL,
|
||||
account UUID NOT NULL,
|
||||
secret TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
confirmed BOOLEAN DEFAULT false
|
||||
);
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||
|
||||
|
@ -72,7 +83,8 @@ CREATE TABLE arimelody.musicrelease (
|
|||
buyname text,
|
||||
buylink text,
|
||||
copyright text,
|
||||
copyrightURL text
|
||||
copyrightURL text,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
);
|
||||
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
|
||||
|
||||
|
@ -118,7 +130,8 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
|
|||
--
|
||||
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_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;
|
||||
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -1,67 +1,58 @@
|
|||
--
|
||||
-- Migration
|
||||
--
|
||||
|
||||
-- Move existing tables to new schema
|
||||
ALTER TABLE public.artist SET SCHEMA arimelody;
|
||||
ALTER TABLE public.musicrelease SET SCHEMA arimelody;
|
||||
ALTER TABLE public.musiclink SET SCHEMA arimelody;
|
||||
ALTER TABLE public.musiccredit SET SCHEMA arimelody;
|
||||
ALTER TABLE public.musictrack SET SCHEMA arimelody;
|
||||
ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody;
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- New items
|
||||
--
|
||||
|
||||
-- Acounts
|
||||
-- Accounts
|
||||
CREATE TABLE arimelody.account (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
email text,
|
||||
avatar_url text,
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp
|
||||
);
|
||||
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
||||
|
||||
-- Privilege
|
||||
CREATE TABLE arimelody.privilege (
|
||||
account uuid NOT NULL,
|
||||
privilege text NOT NULL
|
||||
account UUID NOT NULL,
|
||||
privilege TEXT NOT NULL
|
||||
);
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
|
||||
|
||||
-- Invites
|
||||
CREATE TABLE arimelody.invite (
|
||||
code text NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
|
||||
|
||||
-- Tokens
|
||||
CREATE TABLE arimelody.token (
|
||||
-- Sessions
|
||||
CREATE TABLE arimelody.session (
|
||||
token TEXT,
|
||||
account UUID NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
expires_at TIMESTAMP DEFAULT NULL
|
||||
expires_at TIMESTAMP DEFAULT NULL,
|
||||
account UUID,
|
||||
attempt_account UUID,
|
||||
message TEXT,
|
||||
error TEXT
|
||||
);
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
|
||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
|
||||
|
||||
-- TOTPs
|
||||
-- TOTP methods
|
||||
CREATE TABLE arimelody.totp (
|
||||
name TEXT NOT NULL,
|
||||
account UUID NOT NULL,
|
||||
secret TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
confirmed BOOLEAN DEFAULT false
|
||||
);
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||
|
||||
-- Foreign keys
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.token ADD CONSTRAINT token_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;
|
12
schema-migration/002-audit-logs.sql
Normal file
12
schema-migration/002-audit-logs.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
-- Audit logs
|
||||
CREATE TABLE arimelody.auditlog (
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
level int NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
-- Need moar timestamps
|
||||
ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp;
|
||||
ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL;
|
|
@ -5,29 +5,24 @@ import (
|
|||
"path/filepath"
|
||||
)
|
||||
|
||||
var Pages = map[string]*template.Template{
|
||||
"index": template.Must(template.ParseFiles(
|
||||
filepath.Join("views", "layout.html"),
|
||||
filepath.Join("views", "header.html"),
|
||||
filepath.Join("views", "footer.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("views", "index.html"),
|
||||
)),
|
||||
"music": template.Must(template.ParseFiles(
|
||||
filepath.Join("views", "layout.html"),
|
||||
filepath.Join("views", "header.html"),
|
||||
filepath.Join("views", "footer.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("views", "music.html"),
|
||||
)),
|
||||
"music-gateway": template.Must(template.ParseFiles(
|
||||
filepath.Join("views", "layout.html"),
|
||||
filepath.Join("views", "header.html"),
|
||||
filepath.Join("views", "footer.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("views", "music-gateway.html"),
|
||||
)),
|
||||
}
|
||||
|
||||
var Components = map[string]*template.Template{
|
||||
}
|
||||
var IndexTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("views", "layout.html"),
|
||||
filepath.Join("views", "header.html"),
|
||||
filepath.Join("views", "footer.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("views", "index.html"),
|
||||
))
|
||||
var MusicTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("views", "layout.html"),
|
||||
filepath.Join("views", "header.html"),
|
||||
filepath.Join("views", "footer.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("views", "music.html"),
|
||||
))
|
||||
var MusicGatewayTemplate = template.Must(template.ParseFiles(
|
||||
filepath.Join("views", "layout.html"),
|
||||
filepath.Join("views", "header.html"),
|
||||
filepath.Join("views", "footer.html"),
|
||||
filepath.Join("views", "prideflag.html"),
|
||||
filepath.Join("views", "music-gateway.html"),
|
||||
))
|
||||
|
|
|
@ -8,38 +8,36 @@ import (
|
|||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// HTTP HANDLER METHODS
|
||||
|
||||
func MusicHandler(db *sqlx.DB) http.Handler {
|
||||
func MusicHandler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
ServeCatalog(db).ServeHTTP(w, r)
|
||||
ServeCatalog(app).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
release, err := controller.GetRelease(db, r.URL.Path[1:], true)
|
||||
release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ServeGateway(db, release).ServeHTTP(w, r)
|
||||
ServeGateway(app, release).ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func ServeCatalog(db *sqlx.DB) http.Handler {
|
||||
func ServeCatalog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
releases, err := controller.GetAllReleases(db, true, 0, true)
|
||||
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
|
||||
fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -50,25 +48,26 @@ func ServeCatalog(db *sqlx.DB) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
err = templates.Pages["music"].Execute(w, releases)
|
||||
err = templates.MusicTemplate.Execute(w, releases)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ServeGateway(db *sqlx.DB, release *model.Release) http.Handler {
|
||||
func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only allow authorised users to view hidden releases
|
||||
privileged := false
|
||||
if !release.Visible {
|
||||
account, err := controller.GetAccountByRequest(db, r)
|
||||
session, err := controller.GetSessionFromRequest(app.DB, r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
|
||||
if session != nil && session.Account != nil {
|
||||
// TODO: check privilege on release
|
||||
privileged = true
|
||||
}
|
||||
|
@ -87,7 +86,7 @@ func ServeGateway(db *sqlx.DB, release *model.Release) http.Handler {
|
|||
response.Links = release.Links
|
||||
}
|
||||
|
||||
err := templates.Pages["music-gateway"].Execute(w, response)
|
||||
err := templates.MusicGatewayTemplate.Execute(w, response)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)
|
||||
|
|
|
@ -6,16 +6,16 @@
|
|||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>
|
||||
# 404 - not found!
|
||||
</h1>
|
||||
<h1>
|
||||
# 404 - not found!
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
the page you're looking for does not exist.
|
||||
<br>
|
||||
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
|
||||
</p>
|
||||
<p>
|
||||
the page you're looking for does not exist.
|
||||
<br>
|
||||
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
|
||||
</p>
|
||||
|
||||
<p><small>status: ERR_NOT_FOUND</small></p>
|
||||
<p><small>status: ERR_NOT_FOUND</small></p>
|
||||
</main>
|
||||
{{end}}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{{define "footer"}}
|
||||
|
||||
<footer>
|
||||
<div id="footer">
|
||||
<small><em>*made with ♥ by ari, 2024*</em></small>
|
||||
</div>
|
||||
<div id="footer">
|
||||
<small><em>*made with <span aria-label="love">♥</span> by ari, 2025*</em></small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{end}}
|
||||
|
|
|
@ -1,44 +1,44 @@
|
|||
{{define "header"}}
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<div id="header-home">
|
||||
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
|
||||
<div id="header-text">
|
||||
<h1>ari melody</h1>
|
||||
<h2>your local SPACEGIRL 💫</h2>
|
||||
</div>
|
||||
</div>
|
||||
<a id="header-links-toggle">
|
||||
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
|
||||
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
|
||||
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
|
||||
</svg>
|
||||
</a>
|
||||
<ul id="header-links">
|
||||
<li>
|
||||
<a href="/" preload="mouseover">home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/music" preload="mouseover">music</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
|
||||
</li>
|
||||
<li>
|
||||
<!-- coming later! -->
|
||||
<span title="coming later!">blog</span>
|
||||
</li>
|
||||
<li>
|
||||
<!-- coming later! -->
|
||||
<span title="coming later!">art</span>
|
||||
</li>
|
||||
<li id="toggle-crt">
|
||||
<a href="javascript:void(0)">crt</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav>
|
||||
<div id="header-home">
|
||||
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
|
||||
<div id="header-text">
|
||||
<h1>ari melody</h1>
|
||||
<h2>your local SPACEGIRL 💫</h2>
|
||||
</div>
|
||||
</div>
|
||||
<a id="header-links-toggle">
|
||||
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
|
||||
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
|
||||
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
|
||||
</svg>
|
||||
</a>
|
||||
<ul id="header-links">
|
||||
<li>
|
||||
<a href="/" preload="mouseover">home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/music" preload="mouseover">music</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
|
||||
</li>
|
||||
<li>
|
||||
<!-- coming later! -->
|
||||
<span title="coming later!">blog</span>
|
||||
</li>
|
||||
<li>
|
||||
<!-- coming later! -->
|
||||
<span title="coming later!">art</span>
|
||||
</li>
|
||||
<li id="toggle-crt">
|
||||
<a href="javascript:void(0)">crt</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{end}}
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
<link rel="me" href="https://ice.arimelody.me/@ari">
|
||||
<link rel="me" href="https://wetdry.world/@ari">
|
||||
|
||||
<script type="module" src="/script/index.js" defer> </script>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
@ -66,9 +68,9 @@
|
|||
<strong>my colours 🌈</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>primary: <span class="col-primary">#b7fd49</span></li>
|
||||
<li>secondary: <span class="col-secondary">#f8e05b</span></li>
|
||||
<li>tertiary: <span class="col-tertiary">#f788fe</span></li>
|
||||
<li>primary: <span class="col-primary" id="hex-primary">#b7fd49</span></li>
|
||||
<li>secondary: <span class="col-secondary" id="hex-secondary">#f8e05b</span></li>
|
||||
<li>tertiary: <span class="col-tertiary" id="hex-tertiary">#f788fe</span></li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
@ -10,25 +10,25 @@
|
|||
{{block "head" .}}{{end}}
|
||||
|
||||
<script type="module", src="/script/main.js"></script>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
{{template "header"}}
|
||||
|
||||
{{block "content" .}}
|
||||
<main>
|
||||
<h1>
|
||||
# hello, world!
|
||||
</h1>
|
||||
<p>
|
||||
this is a default page!
|
||||
</p>
|
||||
<h1>
|
||||
# hello, world!
|
||||
</h1>
|
||||
<p>
|
||||
this is a default page!
|
||||
</p>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{template "footer"}}
|
||||
<div id="overlay"></div>
|
||||
{{template "prideflag"}}
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
{{define "prideflag"}}
|
||||
<a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
|
||||
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
||||
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
||||
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
||||
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
||||
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
||||
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
|
||||
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
||||
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
||||
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
||||
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
||||
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
||||
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
||||
|
||||
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
||||
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
||||
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
||||
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
||||
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
||||
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
||||
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
||||
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
||||
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
||||
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
||||
|
||||
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
||||
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
||||
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
</a>
|
||||
{{end}}
|
||||
|
|
Loading…
Reference in a new issue