Compare commits

...

36 commits

Author SHA1 Message Date
b91b6e7ce0
polished up TOTP enrolment 2025-01-26 20:37:20 +00:00
d2ac66a81a
merge feature/accountsettings into dev 2025-01-26 20:09:55 +00:00
3450d879ac
QR codes complete, account settings finished!
+ refactored templates a little; this might need more work!
2025-01-26 20:09:18 +00:00
1edc051ae2
fixed GetTOTP, started rough QR code implementation
GetTOTP handles TOTP method retrieval for confirmation and deletion.

QR code implementation looks like it's gonna suck, so might end up
using a library for this later.
2025-01-26 00:48:19 +00:00
ad39e68cd6
fix API endpoints which require account authorisation 2025-01-24 18:49:04 +00:00
090de0554b
fix bug causing edit tracks component to crash 2025-01-24 01:33:14 +00:00
9a27dbdc37
fixed style of inputs on release edit page (whoops!) 2025-01-24 01:18:46 +00:00
e004491b55
TOTP fully functioning, account settings done! 2025-01-23 13:53:06 +00:00
50cbce92fc
TOTP methods can now be created on the frontend! 2025-01-23 12:09:46 +00:00
e457e979ff
tidying some things up
session message handling is pretty annoying; should look into a better method of doing this
2025-01-23 09:39:40 +00:00
45f33b8b46
terrible no good massive refactor commit (oh yeah and built generic sessions for admin panel) 2025-01-23 00:37:19 +00:00
cee99a6932
merge main into feature/accountsettings 2025-01-22 11:40:44 +00:00
2ee874b2ca
merge main into dev 2025-01-22 11:40:08 +00:00
0052c470f9
(incomplete) change password feature 2025-01-21 17:13:06 +00:00
5531ef6bab
remove account API endpoints
account management should be done on the frontend.
some work will need to be done to generate API keys for external clients,
but notably some API endpoints are currently used by the frontend using session tokens.
2025-01-21 15:08:59 +00:00
384579ee5e
refactored out global. long live AppState 2025-01-21 14:58:13 +00:00
3d674515ce
fixed issues with first-time setup, added config.db.port 2025-01-21 14:12:21 +00:00
7bea6b548e
fix .gitignore 2025-01-21 13:27:56 +00:00
7964fbbf99
update readme 2025-01-21 01:03:58 +00:00
686eea09a5
more account settings page improvements, among others 2025-01-21 01:01:33 +00:00
39b332b477
working TOTP codes YIPPEE 2025-01-21 00:30:43 +00:00
7044f7344b
very rough updates to admin pages, reduced reliance on global.DB 2025-01-21 00:20:07 +00:00
ae254dd731
totp codes don't seem to sync but they're here!! 2025-01-20 23:52:26 +00:00
5e493554dc
listAccounts command 2025-01-20 19:28:59 +00:00
be5cd05d08
disable api account endpoints (for now) 2025-01-20 19:02:26 +00:00
570cdf6ce2
schema migration and account fixes
very close to rolling this out! just need to address some security concerns first
2025-01-20 18:55:05 +00:00
5566a795da
merged main, dev, and i guess got accounts working??
i am so good at commit messages :3
2025-01-20 15:08:01 +00:00
35d3ce5c5d
merge main into dev 2025-01-20 11:47:38 +00:00
d5f1fcb5e0
this is immensely broken but i swear i'll fix it later 2025-01-20 10:34:49 +00:00
ea3a386601
update docker compose: restart unless stopped 2024-11-09 22:58:35 +00:00
34dd280fba
moved accounts to MVC directories 2024-11-01 21:03:08 +00:00
819ec891e7
merge main into dev 2024-11-01 19:43:05 +00:00
e2ec731109
add more detail to credits on /api/v1/artist/{id} 2024-10-20 20:05:46 +01:00
f7edece0af
API login/register/delete-account, automatic db schema init 2024-09-23 00:57:23 +01:00
1846203076
hide hidden releases from unauthorised /api/v1/artist/{id} 2024-09-12 09:56:22 +01:00
e69cf78e57
add artists list to /api/v1/music 2024-09-12 09:46:40 +01:00
66 changed files with 3134 additions and 963 deletions

View file

@ -3,10 +3,11 @@
.air.toml/
.gitattributes
.gitignore
uploads/*
uploads/
test/
tmp/
res/
docker-compose.yml
docker-compose-test.yml
Dockerfile
schema.sql

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ uploads/
docker-compose*.yml
!docker-compose.example.yml
config*.toml
arimelody-web

View file

@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web
# ---
FROM build-stage AS build-release-stage
FROM scratch
WORKDIR /app

View file

@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff!
## running
the server should be run once to generate a default `config.toml` file.
configure as needed. note that a valid DB connection is required, and the admin
panel will be disabled without valid discord app credentials (this can however
be bypassed by running the server with `-adminBypass`).
configure as needed. a valid DB connection is required to run this website.
if no admin users exist, an invite code will be provided. invite codes are
the only way to create admin accounts at this time.
the configuration may be overridden using environment variables in the format
`ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may
@ -32,8 +32,17 @@ be overridden with `ARIMELODY_DB_HOST`.
the location of the configuration file can also be overridden with
`ARIMELODY_CONFIG`.
## database
### command arguments
the server requires a postgres database to run. you can use the
[schema.sql](schema.sql) provided in this repo to generate the required tables.
automatic schema building/migration may come in a future update.
by default, `arimelody-web` will spin up a web server as usual. instead,
arguments may be supplied to run administrative actions. the web server doesn't
need to be up for this, making this ideal for some offline maintenance.
- `createTOTP <username> <name>`: Creates a timed one-time passcode method.
- `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.
- `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes.
- `listAccounts`: Lists all active accounts.
- `deleteAccount <username>`: Deletes an account with a given `username`.

389
admin/accounthttp.go Normal file
View file

@ -0,0 +1,389 @@
package admin
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"time"
"arimelody-web/controller"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
)
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 accountIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
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 (
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"),
})
}
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 {
fmt.Printf("WARN: Failed to render admin account page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func changePasswordHandler(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)
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
}
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
}
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)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("password") || !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
session := r.Context().Value("session").(*model.Session)
// check password
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
fmt.Printf(
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp"))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totpMethod == nil {
fmt.Printf(
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
}
err = controller.DeleteAccount(app.DB, session.Account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
fmt.Printf(
"[%s] INFO: Account \"%s\" deleted by user request.\n",
time.Now().Format(time.UnixDate),
session.Account.Username,
)
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)
})
}
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
}
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
}
if len(r.URL.Path) < 2 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.URL.Path[1:]
session := r.Context().Value("session").(*model.Session)
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totp == nil {
http.NotFound(w, r)
return
}
err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name)
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
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)
})
}

View file

@ -1,58 +0,0 @@
package admin
import (
"fmt"
"math/rand"
"os"
"time"
"arimelody-web/global"
)
type (
Session struct {
Token string
UserID string
Expires time.Time
}
)
const TOKEN_LENGTH = 64
const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var ADMIN_BYPASS = func() bool {
if global.Args["adminBypass"] == "true" {
fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)")
return true
}
return false
}()
var ADMIN_ID_DISCORD = func() string {
id := os.Getenv("DISCORD_ADMIN_ID")
if id == "" { id = global.Config.Discord.AdminID }
if id == "" {
fmt.Printf("WARN: discord.admin_id not provided. Admin login will be unavailable.\n")
}
return id
}()
var sessions []*Session
func createSession(username string, expires time.Time) Session {
return Session{
Token: string(generateToken()),
UserID: username,
Expires: expires,
}
}
func generateToken() string {
var token []byte
for i := 0; i < TOKEN_LENGTH; i++ {
token = append(token, TOKEN_CHARS[rand.Intn(len(TOKEN_CHARS))])
}
return string(token)
}

View file

@ -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,19 +24,26 @@ 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)
return
}
type Artist struct {
*model.Artist
Credits []*model.Credit
type ArtistResponse struct {
Session *model.Session
Artist *model.Artist
Credits []*model.Credit
}
err = pages["artist"].Execute(w, Artist{ Artist: artist, Credits: credits })
session := r.Context().Value("session").(*model.Session)
err = artistTemplate.Execute(w, ArtistResponse{
Session: session,
Artist: artist,
Credits: credits,
})
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)

View file

@ -52,7 +52,6 @@
makeMagicList(creditList, ".credit");
function rigCredit(el) {
console.log(el);
const artistID = el.dataset.artist;
const deleteBtn = el.querySelector("a.delete");

View file

@ -3,21 +3,21 @@
<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 .Tracks}}
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true">
{{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">{{.Number}}</span>
{{.Title}}
<span class="track-number">{{.Add $i 1}}</span>
{{$track.Title}}
</p>
<a class="delete">Delete</a>
</div>

View file

@ -2,6 +2,7 @@ package admin
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
@ -9,229 +10,392 @@ import (
"strings"
"time"
"arimelody-web/discord"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
)
type loginData struct {
DiscordURI string
Token string
}
func Handler() http.Handler {
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
mux.Handle("/login", LoginHandler())
mux.Handle("/logout", MustAuthorise(LogoutHandler()))
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("/logout", requireAccount(app, logoutHandler(app)))
mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(app, accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app))))
mux.Handle("/release/", requireAccount(app, http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(app, http.StripPrefix("/track", serveTrack(app))))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist())))
mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack())))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/", requireAccount(app, 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
}
session := GetSession(r)
if session == nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Printf("FATAL: Failed to pull releases: %s\n", err)
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(global.DB)
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists: %s\n", err)
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(global.DB)
if err != nil {
fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
Session *model.Session
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
err = pages["index"].Execute(w, IndexData{
err = indexTemplate.Execute(w, IndexData{
Session: session,
Releases: releases,
Artists: artists,
Tracks: tracks,
})
if err != nil {
fmt.Printf("Error executing template: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}))
return mux
}
func MustAuthorise(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := GetSession(r)
if session == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
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
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetSession(r *http.Request) *Session {
if ADMIN_BYPASS {
return &Session{}
}
func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
var token = ""
// is the session token in context?
var ctx_session = r.Context().Value("session")
if ctx_session != nil {
token = ctx_session.(*Session).Token
}
// okay, is it in the auth header?
if token == "" {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = r.Header.Get("Authorization")[7:]
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
}
// finally, is it in the cookie?
if token == "" {
cookie, err := r.Cookie("token")
if err != nil {
return nil
}
token = cookie.Value
}
var session *Session = nil
for _, s := range sessions {
if s.Expires.Before(time.Now()) {
// expired session. remove it from the list!
new_sessions := []*Session{}
for _, ns := range sessions {
if ns.Token == s.Token {
continue
}
new_sessions = append(new_sessions, ns)
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)
}
sessions = new_sessions
continue
}
if s.Token == token {
session = s
break
if r.Method == http.MethodGet {
render()
return
}
}
return session
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure invite code exists in DB
invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
controller.SetSessionError(app.DB, session, "Invalid invite code.")
render()
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: sql.NullString{ String: credentials.Email, Valid: true },
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
render()
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
fmt.Printf(
"[%s]: Account registered: %s (%s)\n",
time.Now().Format(time.UnixDate),
account.Username,
account.ID,
)
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", 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() http.Handler {
func loginHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !discord.CREDENTIALS_PROVIDED || ADMIN_ID_DISCORD == "" {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
code := r.URL.Query().Get("code")
session := r.Context().Value("session").(*model.Session)
if code == "" {
pages["login"].Execute(w, loginData{DiscordURI: discord.REDIRECT_URI})
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
}
auth_token, err := discord.GetOAuthTokenFromCode(code)
err := r.ParseForm()
if err != nil {
fmt.Printf("Failed to retrieve discord access token: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
discord_user, err := discord.GetDiscordUserFromAuth(auth_token)
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"),
}
account, err := controller.GetAccountByUsername(app.DB, credentials.Username)
if err != nil {
fmt.Printf("Failed to retrieve discord user information: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if discord_user.ID != ADMIN_ID_DISCORD {
// TODO: unauthorized user; revoke the token
fmt.Printf("Unauthorized login attempted: %s\n", discord_user.ID)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
fmt.Printf(
"[%s] INFO: Account \"%s\" attempted login with incorrect password.\n",
time.Now().Format(time.UnixDate),
account.Username,
)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
var totpMethod *model.TOTP
if len(credentials.TOTP) == 0 {
// check if user has TOTP
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 {
type loginTOTPData struct {
Session *model.Session
Username string
Password string
}
err = loginTOTPTemplate.Execute(w, loginTOTPData{
Session: session,
Username: credentials.Username,
Password: credentials.Password,
})
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
}
}
} else {
totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP)
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 totpMethod == nil {
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
}
if totpMethod != nil {
fmt.Printf(
"[%s] INFO: Account \"%s\" logged in with method \"%s\"\n",
time.Now().Format(time.UnixDate),
account.Username,
totpMethod.Name,
)
} else {
fmt.Printf(
"[%s] INFO: Account \"%s\" logged in\n",
time.Now().Format(time.UnixDate),
account.Username,
)
}
// TODO: log login activity to user
// login success!
session := createSession(discord_user.Username, time.Now().Add(24 * time.Hour))
sessions = append(sessions, &session)
cookie := http.Cookie{}
cookie.Name = "token"
cookie.Value = session.Token
cookie.Expires = time.Now().Add(24 * time.Hour)
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, loginData{Token: session.Token})
if err != nil {
fmt.Printf("Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
controller.SetSessionAccount(app.DB, session, account)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LogoutHandler() http.Handler {
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 := GetSession(r)
// remove this session from the list
sessions = func (token string) []*Session {
new_sessions := []*Session{}
for _, session := range sessions {
if session.Token != token {
new_sessions = append(new_sessions, session)
}
}
return new_sessions
}(session.Token)
err := pages["logout"].Execute(w, nil)
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Printf("Error rendering admin logout page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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(app *model.AppState, next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account == nil {
// TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
@ -255,3 +419,53 @@ 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) {
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
if err != nil && err != http.ErrNoCookie {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
var session *model.Session
if sessionCookie != nil {
// fetch existing session
session, err = controller.GetSession(app.DB, sessionCookie.Value)
if err != nil && !strings.Contains(err.Error(), "no rows") {
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 {
// TODO: consider running security checks here (i.e. user agent mismatches)
}
}
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))
})
}

View file

@ -5,17 +5,18 @@ 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]
release, err := controller.GetRelease(global.DB, releaseID, true)
session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -26,22 +27,16 @@ func serveRelease() http.Handler {
return
}
authorised := GetSession(r) != nil
if !authorised && !release.Visible {
http.NotFound(w, r)
return
}
if len(slices) > 1 {
switch slices[1] {
case "editcredits":
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)
@ -50,17 +45,25 @@ 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)
return
}
err = pages["release"].Execute(w, release)
type ReleaseResponse struct {
Session *model.Session
Release *model.Release
}
err = releaseTemplate.Execute(w, ReleaseResponse{
Session: session,
Release: release,
})
if err != nil {
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -71,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)
@ -79,9 +82,9 @@ 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)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -94,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,
})
@ -105,10 +108,10 @@ 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)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -120,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)
@ -131,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)
@ -142,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)
@ -150,9 +162,9 @@ 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)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -165,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,
})
@ -177,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)
@ -192,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)

View file

@ -24,7 +24,7 @@ nav {
justify-content: left;
background: #f8f8f8;
border-radius: .5em;
border-radius: 4px;
border: 1px solid #808080;
}
nav .icon {
@ -43,7 +43,7 @@ nav .title {
color: inherit;
}
nav a {
.nav-item {
width: auto;
height: 100%;
@ -53,16 +53,17 @@ nav a {
display: flex;
line-height: 2em;
text-decoration: none;
color: inherit;
}
nav a:hover {
.nav-item:hover {
background: #00000010;
text-decoration: none;
}
nav a {
text-decoration: none;
color: inherit;
}
nav #logout {
margin-left: auto;
/* margin-left: auto; */
}
main {
@ -84,6 +85,15 @@ a img.icon {
height: .8em;
}
code {
background: #303030;
color: #f0f0f0;
padding: .23em .3em;
border-radius: 4px;
}
.card {
margin-bottom: 1em;
}
@ -92,13 +102,6 @@ a img.icon {
margin: 0 0 .5em 0;
}
/*
.card h3,
.card p {
margin: 0;
}
*/
.card-title {
margin-bottom: 1em;
display: flex;
@ -114,8 +117,112 @@ a img.icon {
margin: 0;
}
.flex-fill {
flex-grow: 1;
}
@media screen and (max-width: 520px) {
body {
font-size: 12px;
}
}
#message,
#error {
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: 4px;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
.button, button {
color: inherit;
}
.button.new, button.new {
background: #c4ff6a;
border-color: #84b141;
}
.button.save, button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
.button.delete, button.delete {
background: #ff7171;
border-color: #7d3535;
}
.button:hover, button:hover {
background: #fff;
border-color: #d0d0d0;
}
.button:active, button:active {
background: #d0d0d0;
border-color: #808080;
}
.button[disabled], button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
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;
}

View file

@ -0,0 +1,48 @@
@import url("/admin/static/index.css");
div.card {
margin-bottom: 2rem;
}
label {
width: auto;
margin: 0;
display: flex;
align-items: center;
color: inherit;
}
input {
width: min(20rem, calc(100% - 1rem));
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
.mfa-device {
padding: .75em;
background: #f8f8f8f8;
border: 1px solid #808080;
border-radius: 8px;
margin-bottom: .5em;
display: flex;
justify-content: space-between;
}
.mfa-device div {
display: flex;
flex-direction: column;
justify-content: center;
}
.mfa-device p {
margin: 0;
}
.mfa-device .mfa-device-name {
font-weight: bold;
}

View file

View file

@ -9,7 +9,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -66,54 +66,6 @@ input[type="text"]:focus {
border-color: #808080;
}
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.artist-actions {
margin-top: auto;
display: flex;

View file

@ -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;
}
@ -109,58 +109,6 @@ input[type="text"] {
padding: 0;
}
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.new {
background: #c4ff6a;
border-color: #84b141;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.release-actions {
margin-top: auto;
display: flex;
@ -212,7 +160,7 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -222,7 +170,7 @@ dialog div.dialog-actions {
}
.card.credits .credit .artist-avatar {
border-radius: .5em;
border-radius: 8px;
}
.card.credits .credit .artist-name {
@ -248,7 +196,7 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -267,7 +215,7 @@ dialog div.dialog-actions {
}
#editcredits .credit .artist-avatar {
border-radius: .5em;
border-radius: 8px;
}
#editcredits .credit .credit-info {
@ -280,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;
@ -293,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;
@ -421,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;
@ -445,7 +400,7 @@ dialog div.dialog-actions {
flex-direction: column;
gap: .5em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

View file

@ -11,7 +11,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
@ -67,54 +67,6 @@ h1 {
border-color: #808080;
}
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.track-actions {
margin-top: 1em;
display: flex;

View file

@ -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;
}
@ -98,4 +80,3 @@
.track .empty {
opacity: 0.75;
}

View file

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

View file

@ -1,55 +1,90 @@
package admin
import (
"html/template"
"path/filepath"
"html/template"
"path/filepath"
)
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"),
)),
"logout": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.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 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"),
))
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 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"),
))
"editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))),
var editLinksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "links", "editlinks.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 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"),
))

View file

@ -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,19 +24,26 @@ 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)
return
}
type Track struct {
*model.Track
type TrackResponse struct {
Session *model.Session
Track *model.Track
Releases []*model.Release
}
err = pages["track"].Execute(w, Track{ Track: track, Releases: releases })
session := r.Context().Value("session").(*model.Session)
err = trackTemplate.Execute(w, TrackResponse{
Session: session,
Track: track,
Releases: releases,
})
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)

View file

@ -0,0 +1,84 @@
{{define "head"}}
<title>Account Settings - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-account.css">
{{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}}
<h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card">
<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">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" id="confirm-password" value="" autocomplete="new-password" required>
<button type="submit" class="save">Change Password</button>
</form>
</div>
<div class="card-title">
<h2>MFA Devices</h2>
</div>
<div class="card mfa-devices">
{{if .TOTPs}}
{{range .TOTPs}}
<div class="mfa-device">
<div>
<p class="mfa-device-name">{{.TOTP.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div>
<div>
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
</div>
</div>
{{end}}
{{else}}
<p>You have no MFA devices.</p>
{{end}}
<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">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will need to enter your password and TOTP below.
</p>
<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>
<script type="module" src="/admin/static/edit-account.js" defer></script>
{{end}}

View file

@ -1,6 +1,6 @@
{{define "head"}}
<title>Editing {{.Name}} - ari melody 💫</title>
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-artist.css">
{{end}}
@ -8,20 +8,20 @@
<main>
<h1>Editing Artist</h1>
<div id="artist" data-id="{{.ID}}">
<div id="artist" data-id="{{.Artist.ID}}">
<div class="artist-avatar">
<img src="{{.Avatar}}" alt="" width="256" loading="lazy" id="avatar">
<img src="{{.Artist.Avatar}}" alt="" width="256" loading="lazy" id="avatar">
<input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-avatar">Remove</button>
</div>
<div class="artist-info">
<p class="attribute-header">Name</p>
<h2 class="artist-name">
<input type="text" id="name" name="artist-name" value="{{.Name}}">
<input type="text" id="name" name="artist-name" value="{{.Artist.Name}}">
</h2>
<p class="attribute-header">Website</p>
<input type="text" id="website" name="website" value="{{.Website}}">
<input type="text" id="website" name="website" value="{{.Artist.Website}}">
<div class="artist-actions">
<button type="submit" class="save" id="save" disabled>Save</button>

View file

@ -1,28 +1,27 @@
{{define "head"}}
<title>Editing {{.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.GetArtwork}}" type="image/x-icon">
<title>Editing {{.Release.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-release.css">
{{end}}
{{define "content"}}
<main>
<div id="release" data-id="{{.ID}}">
<div id="release" data-id="{{.Release.ID}}">
<div class="release-artwork">
<img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
<img src="{{.Release.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
<input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-artwork">Remove</button>
</div>
<div class="release-info">
<h1 class="release-title">
<input type="text" id="title" name="Title" value="{{.Title}}" autocomplete="on">
<input type="text" id="title" name="Title" value="{{.Release.Title}}" autocomplete="on">
</h1>
<table>
<tr>
<td>Type</td>
<td>
{{$t := .ReleaseType}}
{{$t := .Release.ReleaseType}}
<select name="Type" id="type">
<option value="single" {{if eq $t "single"}}selected{{end}}>
Single
@ -44,71 +43,71 @@
<td>
<textarea
name="Description"
value="{{.Description}}"
value="{{.Release.Description}}"
placeholder="No description provided."
rows="3"
id="description"
>{{.Description}}</textarea>
>{{.Release.Description}}</textarea>
</td>
</tr>
<tr>
<td>Release Date</td>
<td>
<input type="datetime-local" name="release-date" id="release-date" value="{{.TextReleaseDate}}">
<input type="datetime-local" name="release-date" id="release-date" value="{{.Release.TextReleaseDate}}">
</td>
</tr>
<tr>
<td>Buy Name</td>
<td>
<input type="text" name="buyname" id="buyname" value="{{.Buyname}}" autocomplete="on">
<input type="text" name="buyname" id="buyname" value="{{.Release.Buyname}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Buy Link</td>
<td>
<input type="text" name="buylink" id="buylink" value="{{.Buylink}}" autocomplete="on">
<input type="text" name="buylink" id="buylink" value="{{.Release.Buylink}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Copyright</td>
<td>
<input type="text" name="copyright" id="copyright" value="{{.Copyright}}" autocomplete="on">
<input type="text" name="copyright" id="copyright" value="{{.Release.Copyright}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Copyright URL</td>
<td>
<input type="text" name="copyright-url" id="copyright-url" value="{{.CopyrightURL}}" autocomplete="on">
<input type="text" name="copyright-url" id="copyright-url" value="{{.Release.CopyrightURL}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Visible</td>
<td>
<select name="Visibility" id="visibility">
<option value="true" {{if .Visible}}selected{{end}}>True</option>
<option value="false" {{if not .Visible}}selected{{end}}>False</option>
<option value="true" {{if .Release.Visible}}selected{{end}}>True</option>
<option value="false" {{if not .Release.Visible}}selected{{end}}>False</option>
</select>
</td>
</tr>
</table>
<div class="release-actions">
<a href="/music/{{.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
<a href="/music/{{.Release.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
</div>
<div class="card-title">
<h2>Credits ({{len .Credits}})</h2>
<h2>Credits ({{len .Release.Credits}})</h2>
<a class="button edit"
href="/admin/release/{{.ID}}/editcredits"
hx-get="/admin/release/{{.ID}}/editcredits"
href="/admin/release/{{.Release.ID}}/editcredits"
hx-get="/admin/release/{{.Release.ID}}/editcredits"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card credits">
{{range .Credits}}
{{range .Release.Credits}}
<div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info">
@ -122,37 +121,37 @@
</div>
</div>
{{end}}
{{if not .Credits}}
{{if not .Release.Credits}}
<p>There are no credits.</p>
{{end}}
</div>
<div class="card-title">
<h2>Links ({{len .Links}})</h2>
<h2>Links ({{len .Release.Links}})</h2>
<a class="button edit"
href="/admin/release/{{.ID}}/editlinks"
hx-get="/admin/release/{{.ID}}/editlinks"
href="/admin/release/{{.Release.ID}}/editlinks"
hx-get="/admin/release/{{.Release.ID}}/editlinks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card links">
{{range .Links}}
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</div>
<div class="card-title" id="tracks">
<h2>Tracklist ({{len .Tracks}})</h2>
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/release/{{.ID}}/edittracks"
hx-get="/admin/release/{{.ID}}/edittracks"
href="/admin/release/{{.Release.ID}}/edittracks"
hx-get="/admin/release/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card tracks">
{{range $i, $track := .Tracks}}
{{range $i, $track := .Release.Tracks}}
<div class="track" data-id="{{$track.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Add $i 1}}</span>

View file

@ -1,6 +1,6 @@
{{define "head"}}
<title>Editing Track - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-track.css">
{{end}}
@ -8,30 +8,30 @@
<main>
<h1>Editing Track</h1>
<div id="track" data-id="{{.ID}}">
<div id="track" data-id="{{.Track.ID}}">
<div class="track-info">
<p class="attribute-header">Title</p>
<h2 class="track-title">
<input type="text" id="title" name="Title" value="{{.Title}}">
<input type="text" id="title" name="Title" value="{{.Track.Title}}">
</h2>
<p class="attribute-header">Description</p>
<textarea
name="Description"
value="{{.Description}}"
value="{{.Track.Description}}"
placeholder="No description provided."
rows="5"
id="description"
>{{.Description}}</textarea>
>{{.Track.Description}}</textarea>
<p class="attribute-header">Lyrics</p>
<textarea
name="Lyrics"
value="{{.Lyrics}}"
value="{{.Track.Lyrics}}"
placeholder="There are no lyrics."
rows="5"
id="lyrics"
>{{.Lyrics}}</textarea>
>{{.Track.Lyrics}}</textarea>
<div class="track-actions">
<button type="submit" class="save" id="save" disabled>Save</button>

View file

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

View file

@ -17,9 +17,25 @@
<header>
<nav>
<img src="/img/favicon.png" alt="" class="icon">
<a href="/">arimelody.me</a>
<a href="/admin">home</a>
<a href="/admin/logout" id="logout">log out</a>
<div class="nav-item">
<a href="/">arimelody.me</a>
</div>
<div class="nav-item">
<a href="/admin">home</a>
</div>
<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">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav>
</header>

View file

@ -0,0 +1,42 @@
{{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>
<form action="/admin/login" 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>
<input type="hidden" name="username" value="{{.Username}}">
<input type="hidden" name="password" value="{{.Password}}">
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

@ -1,32 +1,50 @@
{{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>
p a {
color: #2a67c8;
form#login {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
a.discord {
color: #5865F2;
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Token}}
<meta http-equiv="refresh" content="5;url=/admin/" />
<p>
Logged in successfully.
You should be redirected to <a href="/admin">/admin</a> in 5 seconds.
</p>
{{else}}
<p>Log in with <a href="{{.DiscordURI}}" class="discord">Discord</a>.</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}}
<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" required autofocus>
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

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

61
admin/views/register.html Normal file
View file

@ -0,0 +1,61 @@
{{define "head"}}
<title>Register - ari melody 💫</title>
<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#register {
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.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/register" method="POST" id="register">
<h1>Create Account</h1>
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="email">Email</label>
<input type="text" name="email" value="" autocomplete="email" required>
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="new-password" required>
<label for="invite">Invite Code</label>
<input type="text" name="invite" value="" autocomplete="off" required>
</div>
<button type="submit" class="new">Create Account</button>
</form>
</main>
{{end}}

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

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

View file

@ -1,23 +1,27 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
func Handler() http.Handler {
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
// TODO: generate API keys on the frontend
// 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(global.DB, artistID)
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -31,13 +35,13 @@ func Handler() 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.MustAuthorise(UpdateArtist(artist)).ServeHTTP(w, r)
requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin)
admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r)
requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -46,10 +50,10 @@ func Handler() 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.MustAuthorise(CreateArtist()).ServeHTTP(w, r)
requireAccount(app, CreateArtist(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -59,7 +63,7 @@ func Handler() 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(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)
@ -73,13 +77,13 @@ func Handler() 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.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r)
requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin)
admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r)
requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -88,10 +92,10 @@ func Handler() 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.MustAuthorise(CreateRelease()).ServeHTTP(w, r)
requireAccount(app, CreateRelease(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -101,7 +105,7 @@ func Handler() 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(global.DB, trackID)
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
@ -115,13 +119,13 @@ func Handler() http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track/{id} (admin)
admin.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r)
requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r)
case http.MethodPut:
// PUT /api/v1/track/{id} (admin)
admin.MustAuthorise(UpdateTrack(track)).ServeHTTP(w, r)
requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r)
case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin)
admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r)
requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -130,10 +134,10 @@ func Handler() http.Handler {
switch r.Method {
case http.MethodGet:
// GET /api/v1/track (admin)
admin.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r)
requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r)
case http.MethodPost:
// POST /api/v1/track (admin)
admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r)
requireAccount(app, CreateTrack(app)).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -141,3 +145,51 @@ func Handler() http.Handler {
return mux
}
func requireAccount(app *model.AppState, next http.Handler) http.Handler {
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
}
if 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
}

View file

@ -10,18 +10,16 @@ import (
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/global"
"arimelody-web/controller"
"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("FATAL: Failed to serve all artists: %s\n", err)
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -36,7 +34,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 {
@ -53,12 +51,12 @@ func ServeArtist(artist *model.Artist) http.Handler {
}
)
show_hidden_releases := admin.GetSession(r) != nil
session := r.Context().Value("session").(*model.Session)
show_hidden_releases := session != nil && session.Account != nil
var dbCredits []*model.Credit
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("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err)
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -88,7 +86,7 @@ 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) {
var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist)
@ -103,13 +101,13 @@ 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)
return
}
fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err)
fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -118,11 +116,11 @@ func CreateArtist() http.Handler {
})
}
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) {
err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil {
fmt.Printf("FATAL: Failed to update artist: %s\n", err)
fmt.Printf("WARN: Failed to update artist: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
@ -132,7 +130,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 {
@ -151,27 +149,27 @@ 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)
return
}
fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err)
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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)
err := controller.DeleteArtist(app.DB, artist.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err)
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

View file

@ -10,19 +10,25 @@ import (
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/global"
"arimelody-web/controller"
"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
authorised := admin.GetSession(r) != nil
if !authorised && !release.Visible {
http.NotFound(w, r)
return
privileged := false
if !release.Visible {
session := r.Context().Value("session").(*model.Session)
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
}
if !privileged {
http.NotFound(w, r)
return
}
}
type (
@ -53,18 +59,18 @@ func ServeRelease(release *model.Release) http.Handler {
Links: make(map[string]string),
}
if authorised || release.IsReleased() {
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("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err)
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("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -77,9 +83,9 @@ 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("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -92,9 +98,9 @@ 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("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -114,9 +120,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
@ -134,11 +140,19 @@ func ServeCatalog() http.Handler {
}
catalog := []Release{}
authorised := admin.GetSession(r) != nil
session := r.Context().Value("session").(*model.Session)
for _, release := range releases {
if !release.Visible && !authorised {
continue
if !release.Visible {
privileged := false
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
}
if !privileged {
continue
}
}
artists := []string{}
for _, credit := range release.Credits {
if !credit.Primary { continue }
@ -167,7 +181,7 @@ 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)
@ -195,13 +209,13 @@ 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)
return
}
fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -218,7 +232,7 @@ 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) {
if r.URL.Path == "/" {
http.NotFound(w, r)
@ -230,11 +244,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
}
@ -256,7 +270,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 {
@ -275,19 +289,19 @@ 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)
return
}
fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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) {
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
@ -296,19 +310,19 @@ 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)
return
}
fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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) {
type creditJSON struct {
Artist string
@ -333,7 +347,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)
@ -343,13 +357,13 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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)
@ -363,27 +377,27 @@ 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)
return
}
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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)
err := controller.DeleteRelease(app.DB, release.ID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err)
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

View file

@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"arimelody-web/global"
"arimelody-web/controller"
"arimelody-web/model"
)
@ -17,7 +16,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,9 +25,9 @@ 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("FATAL: Failed to pull tracks from DB: %s\n", err)
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -44,17 +43,17 @@ func ServeAllTracks() http.Handler {
encoder.SetIndent("", "\t")
err = encoder.Encode(tracks)
if err != nil {
fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err)
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
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)
}
@ -68,13 +67,13 @@ func ServeTrack(track *model.Track) http.Handler {
encoder.SetIndent("", "\t")
err = encoder.Encode(Track{ track, releases })
if err != nil {
fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err)
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
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)
@ -93,9 +92,9 @@ func CreateTrack() http.Handler {
return
}
id, err := controller.CreateTrack(global.DB, &track)
id, err := controller.CreateTrack(app.DB, &track)
if err != nil {
fmt.Printf("FATAL: Failed to create track: %s\n", err)
fmt.Printf("WARN: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -106,7 +105,7 @@ func CreateTrack() http.Handler {
})
}
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 == "/" {
http.NotFound(w, r)
@ -124,9 +123,9 @@ 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("Failed to update track %s: %s\n", track.ID, err)
fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -141,7 +140,7 @@ 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 == "/" {
http.NotFound(w, r)
@ -149,9 +148,9 @@ func DeleteTrack(track *model.Track) http.Handler {
}
var trackID = r.URL.Path[1:]
err := controller.DeleteTrack(global.DB, trackID)
err := controller.DeleteTrack(app.DB, trackID)
if err != nil {
fmt.Printf("Failed to delete track %s: %s\n", trackID, err)
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

View file

@ -1,7 +1,7 @@
package api
import (
"arimelody-web/global"
"arimelody-web/model"
"bufio"
"encoding/base64"
"errors"
@ -11,12 +11,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":

126
controller/account.go Normal file
View file

@ -0,0 +1,126 @@
package controller
import (
"arimelody-web/model"
"net/http"
"strings"
"github.com/jmoiron/sqlx"
)
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
var accounts = []model.Account{}
err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC")
if err != nil {
return nil, err
}
return accounts, nil
}
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)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
var account = model.Account{}
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, 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", sessionToken)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetSessionFromRequest(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(model.COOKIE_TOKEN)
if err != nil {
return ""
}
return cookie.Value
}
func CreateAccount(db *sqlx.DB, account *model.Account) error {
err := db.Get(
&account.ID,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
return err
}
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec(
"UPDATE account " +
"SET username=$2,password=$3,email=$4,avatar_url=$5 " +
"WHERE id=$1",
account.ID,
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
return err
}
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}

View file

@ -2,6 +2,7 @@ package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)

View file

@ -1,48 +1,32 @@
package global
package controller
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"arimelody-web/model"
"github.com/pelletier/go-toml/v2"
)
type (
dbConfig struct {
Host string `toml:"host"`
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: model.DBConfig{
Host: "127.0.0.1",
Port: 5432,
User: "arimelody",
Name: "arimelody",
},
}
data, err := os.ReadFile(configFile)
@ -57,23 +41,22 @@ var Config = func() config {
err = toml.Unmarshal([]byte(data), &config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error())
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 %s\n", err.Error())
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()) }
@ -81,6 +64,10 @@ func handleConfigOverrides(config *config) error {
if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env }
if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env }
if env, has := os.LookupEnv("ARIMELODY_DB_PORT"); has {
config.DB.Port, err = strconv.ParseInt(env, 10, 0)
if err != nil { return errors.New("ARIMELODY_DB_PORT: " + err.Error()) }
}
if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env }
if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env }
if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env }
@ -91,31 +78,3 @@ func handleConfigOverrides(config *config) error {
return nil
}
var Args = func() map[string]string {
args := map[string]string{}
index := 0
for index < len(os.Args[1:]) {
arg := os.Args[index + 1]
if !strings.HasPrefix(arg, "-") {
fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg)
os.Exit(1)
}
if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") {
args[arg[1:]] = "true"
index += 1
continue
}
val := os.Args[index + 2]
args[arg[1:]] = val
// fmt.Printf("%s: %s\n", arg[1:], val)
index += 2
}
return args
}()
var DB *sqlx.DB

13
controller/controller.go Normal file
View file

@ -0,0 +1,13 @@
package controller
import "math/rand"
func GenerateAlnumString(length int) []byte {
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
res := []byte{}
for i := 0; i < length; i++ {
res = append(res, CHARS[rand.Intn(len(CHARS))])
}
return res
}

67
controller/invite.go Normal file
View file

@ -0,0 +1,67 @@
package controller
import (
"arimelody-web/model"
"math/rand"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) {
invite := model.Invite{}
err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &invite, nil
}
func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invite, error) {
invite := model.Invite{
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(lifetime),
}
code := []byte{}
for i := 0; i < length; i++ {
code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)])
}
invite.Code = string(code)
_, err := db.Exec(
"INSERT INTO invite (code, created_at, expires_at) " +
"VALUES ($1, $2, $3)",
invite.Code,
invite.CreatedAt,
invite.ExpiresAt,
)
if err != nil {
return nil, err
}
return &invite, nil
}
func DeleteInvite(db *sqlx.DB, code string) error {
_, err := db.Exec("DELETE FROM invite WHERE code=$1", code)
return err
}
func DeleteAllInvites(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM invite")
return err
}
func DeleteExpiredInvites(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM invite WHERE expires_at<current_timestamp")
return err
}

90
controller/migrator.go Normal file
View file

@ -0,0 +1,90 @@
package controller
import (
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
)
const DB_VERSION int = 2
func CheckDBVersionAndMigrate(db *sqlx.DB) {
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
db.MustExec("SET search_path TO arimelody, public")
db.MustExec(
"CREATE TABLE IF NOT EXISTS arimelody.schema_version (" +
"version INTEGER PRIMARY KEY, " +
"applied_at TIMESTAMP DEFAULT current_timestamp)",
)
oldDBVersion := 0
schemaVersionCount := 0
err := db.Get(&schemaVersionCount, "SELECT COUNT(*) FROM schema_version")
if err != nil { panic(err) }
if schemaVersionCount > 0 {
err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version")
if err != nil { panic(err) }
}
for oldDBVersion < DB_VERSION {
switch oldDBVersion {
case 0:
// default case; assume no database exists
ApplyMigration(db, "000-init")
oldDBVersion = DB_VERSION
case 1:
// the irony is i actually have to awkwardly shove schema_version
// into the old database in order for this to work LOL
ApplyMigration(db, "001-pre-versioning")
oldDBVersion = 2
}
}
fmt.Printf("Database schema up to date.\n")
}
func ApplyMigration(db *sqlx.DB, scriptFile string) {
fmt.Printf("Applying schema migration %s...\n", scriptFile)
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)
}
script := string(bytes)
tx, err := db.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin migration: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec(script)
if err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "FATAL: Failed to apply migration: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec(
"INSERT INTO schema_version (version, applied_at) " +
"VALUES ($1, $2)",
DB_VERSION,
time.Now(),
)
if err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "FATAL: Failed to update schema version: %v\n", err)
os.Exit(1)
}
err = tx.Commit()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to commit transaction: %v\n", err)
os.Exit(1)
}
}

120
controller/qr.go Normal file
View 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)
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
@ -223,7 +224,6 @@ func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link)
return err
}
for _, link := range new_links {
fmt.Printf("%s: %s\n", link.Name, link.URL)
_, err := tx.Exec(
"INSERT INTO musiclink "+
"(release, name, url) "+

130
controller/session.go Normal file
View file

@ -0,0 +1,130 @@
package controller
import (
"database/sql"
"time"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 64
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 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
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
}
}
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
}

165
controller/totp.go Normal file
View file

@ -0,0 +1,165 @@
package controller
import (
"arimelody-web/model"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"os"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
const TOTP_SECRET_LENGTH = 32
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)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
}
counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset)
counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
mac := hmac.New(sha1.New, []byte(decodedSecret))
mac.Write(counterBytes)
hash := mac.Sum(nil)
offset := hash[len(hash) - 1] & 0x0f
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code)
}
func GenerateTOTPSecret(length int) string {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
panic("FATAL: Failed to generate random TOTP bytes")
}
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes)
return strings.ToUpper(secret)
}
func GenerateTOTPURI(username string, secret string) string {
url := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username),
}
query := url.Query()
query.Set("secret", secret)
query.Set("issuer", "arimelody.me")
// 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()
}
func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
totps := []model.TOTP{}
err := db.Select(
&totps,
"SELECT * FROM totp " +
"WHERE account=$1 AND confirmed=true " +
"ORDER BY created_at ASC",
accountID,
)
if err != nil {
return nil, err
}
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 AND name=$2",
accountID,
name,
)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
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) " +
"VALUES ($1,$2,$3)",
totp.AccountID,
totp.Name,
totp.Secret,
)
return err
}
func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
_, err := db.Exec(
"DELETE FROM totp WHERE account=$1 AND name=$2",
accountID,
name,
)
return err
}
func DeleteUnconfirmedTOTPs(db *sqlx.DB) error {
_, err := db.Exec("DELETE FROM totp WHERE confirmed=false")
return err
}

View file

@ -2,6 +2,7 @@ package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)

View file

@ -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 was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
}
return id
}()
var CLIENT_SECRET = func() string {
secret := global.Config.Discord.Secret
if secret == "" {
fmt.Printf("WARN: discord.secret not provided. Admin login will be unavailable.\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),
)
}

View file

@ -1,102 +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
Code int
}
func (lrw *LoggingResponseWriter) WriteHeader(code int) {
lrw.Code = code
lrw.ResponseWriter.WriteHeader(code)
}
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)
}
codeColour := colour.Reset
if lrw.Code - 600 <= 0 { codeColour = colour.Red }
if lrw.Code - 500 <= 0 { codeColour = colour.Yellow }
if lrw.Code - 400 <= 0 { codeColour = colour.White }
if lrw.Code - 300 <= 0 { codeColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate),
r.Method,
r.URL.Path,
codeColour,
lrw.Code,
colour.Reset,
elapsed,
r.Header["User-Agent"][0])
})
}

7
go.mod
View file

@ -7,4 +7,9 @@ require (
github.com/lib/pq v1.10.9
)
require github.com/pelletier/go-toml/v2 v2.2.3 // indirect
require golang.org/x/crypto v0.27.0 // indirect
require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
)

4
go.sum
View file

@ -10,3 +10,7 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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=

488
main.go
View file

@ -4,82 +4,416 @@ import (
"errors"
"fmt"
"log"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/global"
"arimelody-web/colour"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
app := model.AppState{
Config: controller.GetConfig(),
}
// initialise database connection
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env }
if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env }
if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env }
if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env }
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 user=%s dbname=%s password='%s' sslmode=disable",
global.Config.DB.Host,
global.Config.DB.User,
global.Config.DB.Name,
global.Config.DB.Pass,
"host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
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 create database connection pool: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
os.Exit(1)
}
app.DB.SetConnMaxLifetime(time.Minute * 3)
app.DB.SetMaxOpenConns(10)
app.DB.SetMaxIdleConns(10)
defer app.DB.Close()
// handle command arguments
if len(os.Args) > 1 {
arg := os.Args[1]
switch arg {
case "createTOTP":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := 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)
}
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP {
AccountID: account.ID,
Name: totpName,
Secret: string(secret),
}
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
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)
}
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
fmt.Printf("%s\n", url)
return
case "deleteTOTP":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := 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)
}
err = controller.DeleteTOTP(app.DB, account.ID, totpName)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
os.Exit(1)
}
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
return
case "listTOTP":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
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)
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
os.Exit(1)
}
for i, totp := range totps {
fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt)
}
if len(totps) == 0 {
fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username)
}
return
case "testTOTP":
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n")
os.Exit(1)
}
username := os.Args[2]
totpName := 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)
}
totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
if err != nil {
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, "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)
}
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
return
case "createInvite":
fmt.Printf("Creating invite...\n")
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)
}
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(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err)
os.Exit(1)
}
fmt.Printf("Invites deleted successfully.\n")
return
case "listAccounts":
accounts, err := controller.GetAllAccounts(app.DB)
if err != nil {
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" +
"\tEmail: %s\n" +
"\tCreated: %s\n",
account.Username,
account.ID,
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 delete account: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
case "deleteAccount":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username)
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)
}
fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username)
res := ""
fmt.Scanln(&res)
if !strings.HasPrefix(res, "y") {
return
}
err = controller.DeleteAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
}
// command help
fmt.Print(
"Available commands:\n\n" +
"createTOTP <username> <name>:\n\tCreates a timed one-time passcode method.\n" +
"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" +
"listAccounts:\n\tLists all active accounts.\n",
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
)
return
}
// handle DB migrations
controller.CheckDBVersionAndMigrate(app.DB)
// initial invite code
accountsCount := 0
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) }
if accountsCount == 0 {
_, 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(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)
}
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
}
// delete expired invites
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)
}
global.DB.SetConnMaxLifetime(time.Minute * 3)
global.DB.SetMaxOpenConns(10)
global.DB.SetMaxIdleConns(10)
defer global.DB.Close()
// start the web server!
mux := createServeMux()
fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port)
mux := createServeMux(&app)
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
log.Fatal(
http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port),
global.HTTPLog(global.DefaultHeaders(mux)),
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()))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler()))
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler()))
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)
@ -87,7 +421,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)
}
@ -95,7 +429,7 @@ func createServeMux() *http.ServeMux {
}
staticHandler("public").ServeHTTP(w, r)
}))
return mux
}
@ -120,3 +454,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])
})
}

41
model/account.go Normal file
View file

@ -0,0 +1,41 @@
package model
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 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"`
}
AccountPrivilege string
)
const (
Root AccountPrivilege = "root" // grants all permissions. very dangerous to grant!
// unused for now
CreateInvites AccountPrivilege = "create_invites"
ReadAccounts AccountPrivilege = "read_accounts"
EditAccounts AccountPrivilege = "edit_accounts"
ReadReleases AccountPrivilege = "read_releases"
EditReleases AccountPrivilege = "edit_releases"
ReadTracks AccountPrivilege = "read_tracks"
EditTracks AccountPrivilege = "edit_tracks"
ReadArtists AccountPrivilege = "read_artists"
EditArtists AccountPrivilege = "edit_artists"
)

33
model/appstate.go Normal file
View file

@ -0,0 +1,33 @@
package model
import "github.com/jmoiron/sqlx"
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"`
DB DBConfig `toml:"db"`
Discord DiscordConfig `toml:"discord"`
}
AppState struct {
DB *sqlx.DB
Config Config
}
)

10
model/invite.go Normal file
View file

@ -0,0 +1,10 @@
package model
import "time"
type Invite struct {
Code string `db:"code"`
CreatedByID string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}

17
model/session.go Normal file
View file

@ -0,0 +1,17 @@
package model
import (
"database/sql"
"time"
)
type Session struct {
Token string `json:"token" db:"token"`
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"`
Account *Account `json:"-" db:"account"`
Message sql.NullString `json:"-" db:"message"`
Error sql.NullString `json:"-" db:"error"`
}

13
model/totp.go Normal file
View file

@ -0,0 +1,13 @@
package model
import (
"time"
)
type TOTP struct {
Name string `json:"name" db:"name"`
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"`
}

View file

@ -12,6 +12,8 @@ type (
Description string `json:"description"`
Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"`
Number int
}
)

View file

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

View file

@ -91,7 +91,7 @@ hr {
text-align: center;
line-height: 0px;
border-width: 1px 0 0 0;
border-color: #888f;
border-color: #888;
margin: 1.5em 0;
overflow: visible;
}

View file

@ -1,8 +1,58 @@
CREATE SCHEMA arimelody AUTHORIZATION arimelody;
--
-- Tables
--
-- 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
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
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,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
confirmed BOOLEAN DEFAULT false,
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
--
-- Artists (should be applicable to all art)
--
CREATE TABLE arimelody.artist (
id character varying(64),
name text NOT NULL,
@ -11,9 +61,7 @@ CREATE TABLE arimelody.artist (
);
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
--
-- Music releases
--
CREATE TABLE arimelody.musicrelease (
id character varying(64) NOT NULL,
visible bool DEFAULT false,
@ -29,9 +77,7 @@ CREATE TABLE arimelody.musicrelease (
);
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
--
-- Music links (external platform links under a release)
--
CREATE TABLE arimelody.musiclink (
release character varying(64) NOT NULL,
name text NOT NULL,
@ -39,9 +85,7 @@ CREATE TABLE arimelody.musiclink (
);
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
--
-- Music credits (artist credits under a release)
--
CREATE TABLE arimelody.musiccredit (
release character varying(64) NOT NULL,
artist character varying(64) NOT NULL,
@ -50,9 +94,7 @@ CREATE TABLE arimelody.musiccredit (
);
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
--
-- Music tracks (tracks under a release)
--
CREATE TABLE arimelody.musictrack (
id uuid DEFAULT gen_random_uuid(),
title text NOT NULL,
@ -62,9 +104,7 @@ CREATE TABLE arimelody.musictrack (
);
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
--
-- Music release/track pairs
--
CREATE TABLE arimelody.musicreleasetrack (
release character varying(64) NOT NULL,
track uuid NOT NULL,
@ -72,9 +112,16 @@ CREATE TABLE arimelody.musicreleasetrack (
);
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
--
-- Foreign keys
--
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
ALTER TABLE arimelody.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;
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;

View file

@ -0,0 +1,56 @@
--
-- New items
--
-- 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
);
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
-- Privilege
CREATE TABLE arimelody.privilege (
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,
expires_at TIMESTAMP NOT NULL
);
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- Sessions
CREATE TABLE arimelody.session (
token TEXT,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL,
account UUID,
message TEXT,
error TEXT
);
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
-- TOTP methods
CREATE TABLE arimelody.totp (
name TEXT NOT NULL,
account UUID NOT NULL,
secret TEXT,
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.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;

View file

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

View file

@ -4,39 +4,37 @@ import (
"fmt"
"net/http"
"arimelody-web/admin"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model"
"arimelody-web/templates"
)
// HTTP HANDLER METHODS
func MusicHandler() 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().ServeHTTP(w, r)
ServeCatalog(app).ServeHTTP(w, r)
return
}
release, err := controller.GetRelease(global.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(release).ServeHTTP(w, r)
ServeGateway(app, release).ServeHTTP(w, r)
}))
return mux
}
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, 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)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -49,31 +47,39 @@ func ServeCatalog() 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(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
authorised := admin.GetSession(r) != nil
if !authorised && !release.Visible {
http.NotFound(w, r)
return
privileged := false
if !release.Visible {
session := r.Context().Value("session").(*model.Session)
if session != nil && session.Account != nil {
// TODO: check privilege on release
privileged = true
}
if !privileged {
http.NotFound(w, r)
return
}
}
response := *release
if authorised || release.IsReleased() {
if release.IsReleased() || privileged {
response.Tracks = release.Tracks
response.Credits = release.Credits
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)