Compare commits

...

4 commits

Author SHA1 Message Date
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
d5f1fcb5e0
this is immensely broken but i swear i'll fix it later 2025-01-20 10:34:49 +00:00
e2ec731109
add more detail to credits on /api/v1/artist/{id} 2024-10-20 20:05:46 +01:00
40 changed files with 1230 additions and 563 deletions

View file

@ -6,7 +6,6 @@
uploads/ uploads/
test/ test/
tmp/ tmp/
db/
res/ res/
docker-compose.yml docker-compose.yml
docker-compose-test.yml docker-compose-test.yml

1
.gitignore vendored
View file

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

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 WORKDIR /app

View file

@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff!
## running ## running
the server should be run once to generate a default `config.toml` file. 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 configure as needed. a valid DB connection is required to run this website.
panel will be disabled without valid discord app credentials (this can however if no admin users exist, an invite code will be provided. invite codes are
be bypassed by running the server with `-adminBypass`). the only way to create admin accounts at this time.
the configuration may be overridden using environment variables in the format the configuration may be overridden using environment variables in the format
`ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may `ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may
@ -32,6 +32,16 @@ be overridden with `ARIMELODY_DB_HOST`.
the location of the configuration file can also be overridden with the location of the configuration file can also be overridden with
`ARIMELODY_CONFIG`. `ARIMELODY_CONFIG`.
## command arguments
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.
- `createInvite`: Creates an invite code to register new accounts.
- `purgeInvites`: Deletes all available invite codes.
- `deleteAccount <username>`: Deletes an account with a given `username`.
## database ## database
the server requires a postgres database to run. you can use the the server requires a postgres database to run. you can use the

View file

@ -1,48 +0,0 @@
package admin
import (
"fmt"
"math/rand"
"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 sessions []*Session
func createSession(userID string, expires time.Time) Session {
return Session{
Token: string(generateToken()),
UserID: userID,
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

@ -32,12 +32,19 @@ func serveArtist() http.Handler {
return return
} }
type Artist struct { type ArtistResponse struct {
*model.Artist Account *model.Account
Credits []*model.Credit Artist *model.Artist
Credits []*model.Credit
} }
err = pages["artist"].Execute(w, Artist{ Artist: artist, Credits: credits }) account := r.Context().Value("account").(*model.Account)
err = pages["artist"].Execute(w, ArtistResponse{
Account: account,
Artist: artist,
Credits: credits,
})
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

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

View file

@ -12,12 +12,12 @@
<form action="/api/v1/music/{{.ID}}/tracks"> <form action="/api/v1/music/{{.ID}}/tracks">
<ul> <ul>
{{range .Tracks}} {{range $i, $track := .Tracks}}
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true"> <li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
<div> <div>
<p class="track-name"> <p class="track-name">
<span class="track-number">{{.Number}}</span> <span class="track-number">{{$track.Add $i 1}}</span>
{{.Title}} {{$track.Title}}
</p> </p>
<a class="delete">Delete</a> <a class="delete">Delete</a>
</div> </div>

View file

@ -8,17 +8,17 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"encoding/json"
"arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type loginData struct { type TemplateData struct {
DiscordURI string Account *model.Account
Token string Token string
} }
@ -26,57 +26,64 @@ func Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/login", LoginHandler()) mux.Handle("/login", LoginHandler())
mux.Handle("/logout", MustAuthorise(LogoutHandler())) mux.Handle("/register", createAccountHandler())
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
// TODO: /admin/account
mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist()))) mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist())))
mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack()))) mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack())))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
session := GetSession(r) account, err := controller.GetAccountByRequest(global.DB, r)
if session == nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err)
}
if account == nil {
http.Redirect(w, r, "/admin/login", http.StatusFound) http.Redirect(w, r, "/admin/login", http.StatusFound)
return return
} }
releases, err := controller.GetAllReleases(global.DB, false, 0, true) releases, err := controller.GetAllReleases(global.DB, false, 0, true)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
artists, err := controller.GetAllArtists(global.DB) artists, err := controller.GetAllArtists(global.DB)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
tracks, err := controller.GetOrphanTracks(global.DB) tracks, err := controller.GetOrphanTracks(global.DB)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
type IndexData struct { type IndexData struct {
Account *model.Account
Releases []*model.Release Releases []*model.Release
Artists []*model.Artist Artists []*model.Artist
Tracks []*model.Track Tracks []*model.Track
} }
err = pages["index"].Execute(w, IndexData{ err = pages["index"].Execute(w, IndexData{
Account: account,
Releases: releases, Releases: releases,
Artists: artists, Artists: artists,
Tracks: tracks, Tracks: tracks,
}) })
if err != nil { if err != nil {
fmt.Printf("Error executing template: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -85,76 +92,43 @@ func Handler() http.Handler {
return mux return mux
} }
func MustAuthorise(next http.Handler) http.Handler { func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := GetSession(r) account, err := controller.GetAccountByRequest(db, r)
if session == nil { if err != nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account == nil {
// TODO: include context in redirect
http.Redirect(w, r, "/admin/login", http.StatusFound)
return return
} }
ctx := context.WithValue(r.Context(), "session", session) ctx := context.WithValue(r.Context(), "account", account)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
func GetSession(r *http.Request) *Session {
if ADMIN_BYPASS {
return &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:]
}
}
// 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)
}
sessions = new_sessions
continue
}
if s.Token == token {
session = s
break
}
}
return session
}
func LoginHandler() http.Handler { func LoginHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
err := pages["login"].Execute(w, nil) account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account != nil {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
err = pages["login"].Execute(w, TemplateData{})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -166,41 +140,53 @@ func LoginHandler() http.Handler {
return return
} }
type LoginRequest struct { err := r.ParseForm()
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
}
data := LoginRequest{}
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
account, err := controller.GetAccount(global.DB, data.Username) 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.GetAccount(global.DB, credentials.Username)
if err != nil { if err != nil {
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return return
} }
err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password)) err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil { if err != nil {
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) http.Error(w, "Invalid username or password", http.StatusBadRequest)
return return
} }
// TODO: check TOTP // TODO: check TOTP
// login success! // login success!
session := createSession(account.ID, time.Now().Add(24 * time.Hour)) token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
sessions = append(sessions, &session) if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
cookie := http.Cookie{} cookie := http.Cookie{}
cookie.Name = "token" cookie.Name = global.COOKIE_TOKEN
cookie.Value = session.Token cookie.Value = token.Token
cookie.Expires = time.Now().Add(24 * time.Hour) cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") { if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true cookie.Secure = true
} }
@ -208,7 +194,10 @@ func LoginHandler() http.Handler {
cookie.Path = "/" cookie.Path = "/"
http.SetCookie(w, &cookie) http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, loginData{Token: session.Token}) err = pages["login"].Execute(w, TemplateData{
Account: account,
Token: token.Token,
})
if err != nil { if err != nil {
fmt.Printf("Error rendering admin login page: %s\n", err) fmt.Printf("Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -224,22 +213,168 @@ func LogoutHandler() http.Handler {
return return
} }
session := GetSession(r) tokenStr := controller.GetTokenFromRequest(global.DB, r)
// remove this session from the list if len(tokenStr) > 0 {
sessions = func (token string) []*Session { err := controller.DeleteToken(global.DB, tokenStr)
new_sessions := []*Session{} if err != nil {
for _, session := range sessions { fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
if session.Token != token { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
new_sessions = append(new_sessions, session) return
}
} }
return new_sessions }
}(session.Token)
err := pages["logout"].Execute(w, nil) cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = ""
cookie.Expires = time.Now()
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/admin/login", http.StatusFound)
})
}
func createAccountHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkAccount, err := controller.GetAccountByRequest(global.DB, r)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin logout page: %s\n", err) fmt.Printf("WARN: Failed to fetch account: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if checkAccount != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
type CreateAccountResponse struct {
Account *model.Account
Message string
}
render := func(data CreateAccountResponse) {
err := pages["create-account"].Execute(w, data)
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render(CreateAccountResponse{})
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err = r.ParseForm()
if err != nil {
render(CreateAccountResponse{
Message: "Malformed data.",
})
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 code exists in DB
invite, err := controller.GetInvite(global.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
render(CreateAccountResponse{
Message: "Invalid invite code.",
})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: credentials.Email,
AvatarURL: "/img/default-avatar.png",
}
err = controller.CreateAccount(global.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
render(CreateAccountResponse{
Message: "An account with that username already exists.",
})
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
err = controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
// registration success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
// gracefully redirect user to login page
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, TemplateData{
Account: &account,
Token: token.Token,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -15,6 +15,8 @@ func serveRelease() http.Handler {
slices := strings.Split(r.URL.Path[1:], "/") slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0] releaseID := slices[0]
account := r.Context().Value("account").(*model.Account)
release, err := controller.GetRelease(global.DB, releaseID, true) release, err := controller.GetRelease(global.DB, releaseID, true)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
@ -26,12 +28,6 @@ func serveRelease() http.Handler {
return return
} }
authorised := GetSession(r) != nil
if !authorised && !release.Visible {
http.NotFound(w, r)
return
}
if len(slices) > 1 { if len(slices) > 1 {
switch slices[1] { switch slices[1] {
case "editcredits": case "editcredits":
@ -60,7 +56,15 @@ func serveRelease() http.Handler {
return return
} }
err = pages["release"].Execute(w, release) type ReleaseResponse struct {
Account *model.Account
Release *model.Release
}
err = pages["release"].Execute(w, ReleaseResponse{
Account: account,
Release: release,
})
if err != nil { if err != nil {
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -43,7 +43,7 @@ nav .title {
color: inherit; color: inherit;
} }
nav a { .nav-item {
width: auto; width: auto;
height: 100%; height: 100%;
@ -53,16 +53,17 @@ nav a {
display: flex; display: flex;
line-height: 2em; line-height: 2em;
text-decoration: none;
color: inherit;
} }
nav a:hover { .nav-item:hover {
background: #00000010; background: #00000010;
text-decoration: none; text-decoration: none;
} }
nav a {
text-decoration: none;
color: inherit;
}
nav #logout { nav #logout {
margin-left: auto; /* margin-left: auto; */
} }
main { main {
@ -114,6 +115,10 @@ a img.icon {
margin: 0; margin: 0;
} }
.flex-fill {
flex-grow: 1;
}
@media screen and (max-width: 520px) { @media screen and (max-width: 520px) {
body { body {
font-size: 12px; font-size: 12px;

View file

@ -18,6 +18,11 @@ var pages = map[string]*template.Template{
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login.html"), filepath.Join("admin", "views", "login.html"),
)), )),
"create-account": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "create-account.html"),
)),
"logout": template.Must(template.ParseFiles( "logout": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),

View file

@ -32,12 +32,19 @@ func serveTrack() http.Handler {
return return
} }
type Track struct { type TrackResponse struct {
*model.Track Account *model.Account
Track *model.Track
Releases []*model.Release Releases []*model.Release
} }
err = pages["track"].Execute(w, Track{ Track: track, Releases: releases }) account := r.Context().Value("account").(*model.Account)
err = pages["track"].Execute(w, TrackResponse{
Account: account,
Track: track,
Releases: releases,
})
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -0,0 +1,102 @@
{{define "head"}}
<title>Register - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<style>
p a {
color: #2a67c8;
}
a.discord {
color: #5865F2;
}
form {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input {
width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button.new {
background: #c4ff6a;
border-color: #84b141;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{end}}
<form action="/admin/register" method="POST" id="create-account">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="">
<label for="email">Email</label>
<input type="text" name="email" value="">
<label for="password">Password</label>
<input type="password" name="password" value="">
<label for="invite">Invite Code</label>
<input type="text" name="invite" value="">
</div>
<button type="submit" class="new">Create Account</button>
</form>
</main>
{{end}}

View file

@ -1,5 +1,6 @@
{{define "head"}} {{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"> <link rel="stylesheet" href="/admin/static/edit-artist.css">
{{end}} {{end}}
@ -8,20 +9,20 @@
<main> <main>
<h1>Editing Artist</h1> <h1>Editing Artist</h1>
<div id="artist" data-id="{{.ID}}"> <div id="artist" data-id="{{.Artist.ID}}">
<div class="artist-avatar"> <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> <input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-avatar">Remove</button> <button id="remove-avatar">Remove</button>
</div> </div>
<div class="artist-info"> <div class="artist-info">
<p class="attribute-header">Name</p> <p class="attribute-header">Name</p>
<h2 class="artist-name"> <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> </h2>
<p class="attribute-header">Website</p> <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"> <div class="artist-actions">
<button type="submit" class="save" id="save" disabled>Save</button> <button type="submit" class="save" id="save" disabled>Save</button>
@ -36,13 +37,13 @@
{{if .Credits}} {{if .Credits}}
{{range .Credits}} {{range .Credits}}
<div class="credit"> <div class="credit">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> <img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info"> <div class="credit-info">
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3> <h3 class="credit-name"><a href="/admin/release/{{.Artist.Release.ID}}">{{.Artist.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p> <p class="credit-artists">{{.Artist.Release.PrintArtists true true}}</p>
<p class="artist-role"> <p class="artist-role">
Role: {{.Role}} Role: {{.Artist.Role}}
{{if .Primary}} {{if .Artist.Primary}}
<small>(Primary)</small> <small>(Primary)</small>
{{end}} {{end}}
</p> </p>

View file

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

View file

@ -8,30 +8,30 @@
<main> <main>
<h1>Editing Track</h1> <h1>Editing Track</h1>
<div id="track" data-id="{{.ID}}"> <div id="track" data-id="{{.Track.ID}}">
<div class="track-info"> <div class="track-info">
<p class="attribute-header">Title</p> <p class="attribute-header">Title</p>
<h2 class="track-title"> <h2 class="track-title">
<input type="text" id="title" name="Title" value="{{.Title}}"> <input type="text" id="title" name="Title" value="{{.Track.Title}}">
</h2> </h2>
<p class="attribute-header">Description</p> <p class="attribute-header">Description</p>
<textarea <textarea
name="Description" name="Description"
value="{{.Description}}" value="{{.Track.Description}}"
placeholder="No description provided." placeholder="No description provided."
rows="5" rows="5"
id="description" id="description"
>{{.Description}}</textarea> >{{.Track.Description}}</textarea>
<p class="attribute-header">Lyrics</p> <p class="attribute-header">Lyrics</p>
<textarea <textarea
name="Lyrics" name="Lyrics"
value="{{.Lyrics}}" value="{{.Track.Lyrics}}"
placeholder="There are no lyrics." placeholder="There are no lyrics."
rows="5" rows="5"
id="lyrics" id="lyrics"
>{{.Lyrics}}</textarea> >{{.Track.Lyrics}}</textarea>
<div class="track-actions"> <div class="track-actions">
<button type="submit" class="save" id="save" disabled>Save</button> <button type="submit" class="save" id="save" disabled>Save</button>

View file

@ -17,9 +17,22 @@
<header> <header>
<nav> <nav>
<img src="/img/favicon.png" alt="" class="icon"> <img src="/img/favicon.png" alt="" class="icon">
<a href="/">arimelody.me</a> <div class="nav-item">
<a href="/admin">home</a> <a href="/">arimelody.me</a>
<a href="/admin/logout" id="logout">log out</a> </div>
<div class="nav-item">
<a href="/admin">home</a>
</div>
<div class="flex-fill"></div>
{{if .Account}}
<div class="nav-item">
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a>
</div>
{{else}}
<div class="nav-item">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav> </nav>
</header> </header>

View file

@ -10,6 +10,65 @@ p a {
a.discord { a.discord {
color: #5865F2; color: #5865F2;
} }
form {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input {
width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
</style> </style>
{{end}} {{end}}
@ -17,15 +76,28 @@ a.discord {
<main> <main>
{{if .Token}} {{if .Token}}
<meta http-equiv="refresh" content="5;url=/admin/" /> <meta http-equiv="refresh" content="0;url=/admin/" />
<p> <p>
Logged in successfully. Logged in successfully.
You should be redirected to <a href="/admin">/admin</a> in 5 seconds. You should be redirected to <a href="/admin">/admin</a> soon.
</p> </p>
{{else}} {{else}}
<p>Log in with <a href="{{.DiscordURI}}" class="discord">Discord</a>.</p> <form action="/admin/login" method="POST" id="login">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="">
<label for="password">Password</label>
<input type="password" name="password" value="">
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" disabled>
</div>
<button type="submit" class="save">Login</button>
</form>
{{end}} {{end}}
</main> </main>

View file

@ -35,25 +35,35 @@ func handleLogin() http.HandlerFunc {
account, err := controller.GetAccount(global.DB, credentials.Username) account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil { if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid username or password", http.StatusBadRequest)
return return
} }
// TODO: sessions and tokens token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
w.WriteHeader(http.StatusOK) err = json.NewEncoder(w).Encode(LoginResponse{
w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) Token: token.Token,
ExpiresAt: token.ExpiresAt,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}) })
} }
@ -68,7 +78,7 @@ func handleAccountRegistration() http.HandlerFunc {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
Code string `json:"code"` Invite string `json:"invite"`
} }
credentials := RegisterRequest{} credentials := RegisterRequest{}
@ -79,50 +89,65 @@ func handleAccountRegistration() http.HandlerFunc {
} }
// make sure code exists in DB // make sure code exists in DB
invite := model.Invite{} invite, err := controller.GetInvite(global.DB, credentials.Invite)
err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") { fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
http.Error(w, "Invalid invite code", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if invite == nil {
http.Error(w, "Invalid invite code", http.StatusBadRequest)
return
}
if time.Now().After(invite.ExpiresAt) { if time.Now().After(invite.ExpiresAt) {
err := controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
http.Error(w, "Invalid invite code", http.StatusBadRequest) http.Error(w, "Invalid invite code", http.StatusBadRequest)
_, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) }
return return
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
account := model.Account{ account := model.Account{
Username: credentials.Username, Username: credentials.Username,
Password: hashedPassword, Password: string(hashedPassword),
Email: credentials.Email, Email: credentials.Email,
AvatarURL: "/img/default-avatar.png", AvatarURL: "/img/default-avatar.png",
} }
err = controller.CreateAccount(global.DB, &account) err = controller.CreateAccount(global.DB, &account)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error()) if strings.HasPrefix(err.Error(), "pq: duplicate key") {
http.Error(w, "An account with that username already exists", http.StatusBadRequest)
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
_, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) err = controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
w.WriteHeader(http.StatusCreated) token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
w.Write([]byte("Account created successfully\n")) type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
err = json.NewEncoder(w).Encode(LoginResponse{
Token: token.Token,
ExpiresAt: token.ExpiresAt,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}) })
} }
@ -151,20 +176,22 @@ func handleDeleteAccount() http.HandlerFunc {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid username or password", http.StatusBadRequest)
return return
} }
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil { if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid password", http.StatusBadRequest)
return return
} }
err = controller.DeleteAccount(global.DB, account.ID) // TODO: check TOTP
err = controller.DeleteAccount(global.DB, account.Username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -40,10 +40,10 @@ func Handler() http.Handler {
ServeArtist(artist).ServeHTTP(w, r) ServeArtist(artist).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/artist/{id} (admin) // PUT /api/v1/artist/{id} (admin)
admin.MustAuthorise(UpdateArtist(artist)).ServeHTTP(w, r) admin.RequireAccount(global.DB, UpdateArtist(artist)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/artist/{id} (admin) // DELETE /api/v1/artist/{id} (admin)
admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r) admin.RequireAccount(global.DB, DeleteArtist(artist)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -55,7 +55,7 @@ func Handler() http.Handler {
ServeAllArtists().ServeHTTP(w, r) ServeAllArtists().ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/artist (admin) // POST /api/v1/artist (admin)
admin.MustAuthorise(CreateArtist()).ServeHTTP(w, r) admin.RequireAccount(global.DB, CreateArtist()).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -82,10 +82,10 @@ func Handler() http.Handler {
ServeRelease(release).ServeHTTP(w, r) ServeRelease(release).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/music/{id} (admin) // PUT /api/v1/music/{id} (admin)
admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/music/{id} (admin) // DELETE /api/v1/music/{id} (admin)
admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r) admin.RequireAccount(global.DB, DeleteRelease(release)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -97,7 +97,7 @@ func Handler() http.Handler {
ServeCatalog().ServeHTTP(w, r) ServeCatalog().ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/music (admin) // POST /api/v1/music (admin)
admin.MustAuthorise(CreateRelease()).ServeHTTP(w, r) admin.RequireAccount(global.DB, CreateRelease()).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -121,13 +121,13 @@ func Handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/track/{id} (admin) // GET /api/v1/track/{id} (admin)
admin.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r) admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/track/{id} (admin) // PUT /api/v1/track/{id} (admin)
admin.MustAuthorise(UpdateTrack(track)).ServeHTTP(w, r) admin.RequireAccount(global.DB, UpdateTrack(track)).ServeHTTP(w, r)
case http.MethodDelete: case http.MethodDelete:
// DELETE /api/v1/track/{id} (admin) // DELETE /api/v1/track/{id} (admin)
admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r) admin.RequireAccount(global.DB, DeleteTrack(track)).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -136,10 +136,10 @@ func Handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/track (admin) // GET /api/v1/track (admin)
admin.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r) admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r)
case http.MethodPost: case http.MethodPost:
// POST /api/v1/track (admin) // POST /api/v1/track (admin)
admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r) admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View file

@ -10,7 +10,6 @@ import (
"strings" "strings"
"time" "time"
"arimelody-web/admin"
"arimelody-web/global" "arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
@ -21,7 +20,7 @@ func ServeAllArtists() http.Handler {
var artists = []*model.Artist{} var artists = []*model.Artist{}
artists, err := controller.GetAllArtists(global.DB) artists, err := controller.GetAllArtists(global.DB)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -53,12 +52,17 @@ func ServeArtist(artist *model.Artist) http.Handler {
} }
) )
show_hidden_releases := admin.GetSession(r) != nil account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
show_hidden_releases := account != nil
var dbCredits []*model.Credit
dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -109,7 +113,7 @@ func CreateArtist() http.Handler {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -122,7 +126,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := json.NewDecoder(r.Body).Decode(&artist) err := json.NewDecoder(r.Body).Decode(&artist)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
@ -157,7 +161,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -171,7 +175,7 @@ func DeleteArtist(artist *model.Artist) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })

View file

@ -10,7 +10,6 @@ import (
"strings" "strings"
"time" "time"
"arimelody-web/admin"
"arimelody-web/global" "arimelody-web/global"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/model" "arimelody-web/model"
@ -19,10 +18,23 @@ import (
func ServeRelease(release *model.Release) http.Handler { func ServeRelease(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases // only allow authorised users to view hidden releases
authorised := admin.GetSession(r) != nil privileged := false
if !authorised && !release.Visible { if !release.Visible {
http.NotFound(w, r) account, err := controller.GetAccountByRequest(global.DB, r)
return if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account != nil {
// TODO: check privilege on release
privileged = true
}
if !privileged {
http.NotFound(w, r)
return
}
} }
type ( type (
@ -53,18 +65,18 @@ func ServeRelease(release *model.Release) http.Handler {
Links: make(map[string]string), Links: make(map[string]string),
} }
if authorised || release.IsReleased() { if release.IsReleased() || privileged {
// get credits // get credits
credits, err := controller.GetReleaseCredits(global.DB, release.ID) credits, err := controller.GetReleaseCredits(global.DB, release.ID)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
for _, credit := range credits { for _, credit := range credits {
artist, err := controller.GetArtist(global.DB, credit.Artist.ID) artist, err := controller.GetArtist(global.DB, credit.Artist.ID)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -79,7 +91,7 @@ func ServeRelease(release *model.Release) http.Handler {
// get tracks // get tracks
tracks, err := controller.GetReleaseTracks(global.DB, release.ID) tracks, err := controller.GetReleaseTracks(global.DB, release.ID)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -94,7 +106,7 @@ func ServeRelease(release *model.Release) http.Handler {
// get links // get links
links, err := controller.GetReleaseLinks(global.DB, release.ID) links, err := controller.GetReleaseLinks(global.DB, release.ID)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -134,11 +146,24 @@ func ServeCatalog() http.Handler {
} }
catalog := []Release{} catalog := []Release{}
authorised := admin.GetSession(r) != nil account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, release := range releases { for _, release := range releases {
if !release.Visible && !authorised { if !release.Visible {
continue privileged := false
if account != nil {
// TODO: check privilege on release
privileged = true
}
if !privileged {
continue
}
} }
artists := []string{} artists := []string{}
for _, credit := range release.Credits { for _, credit := range release.Credits {
if !credit.Primary { continue } if !credit.Primary { continue }
@ -201,7 +226,7 @@ func CreateRelease() http.Handler {
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -281,7 +306,7 @@ func UpdateRelease(release *model.Release) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -302,7 +327,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -343,7 +368,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -369,7 +394,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -383,7 +408,7 @@ func DeleteRelease(release *model.Release) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })

View file

@ -28,7 +28,7 @@ func ServeAllTracks() http.Handler {
var dbTracks = []*model.Track{} var dbTracks = []*model.Track{}
dbTracks, err := controller.GetAllTracks(global.DB) dbTracks, err := controller.GetAllTracks(global.DB)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -44,7 +44,7 @@ func ServeAllTracks() http.Handler {
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err = encoder.Encode(tracks) err = encoder.Encode(tracks)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -54,7 +54,7 @@ func ServeTrack(track *model.Track) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -68,7 +68,7 @@ func ServeTrack(track *model.Track) http.Handler {
encoder.SetIndent("", "\t") encoder.SetIndent("", "\t")
err = encoder.Encode(Track{ track, releases }) err = encoder.Encode(Track{ track, releases })
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -95,7 +95,7 @@ func CreateTrack() http.Handler {
id, err := controller.CreateTrack(global.DB, &track) id, err := controller.CreateTrack(global.DB, &track)
if err != nil { 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -1,8 +1,12 @@
package controller package controller
import ( import (
"arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
"math/rand" "errors"
"fmt"
"net/http"
"strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@ -12,6 +16,9 @@ func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err return nil, err
} }
@ -23,49 +30,98 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email) err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err return nil, err
} }
return &account, nil return &account, nil
} }
func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) {
if token == "" { return nil, nil }
account := model.Account{}
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
return &account, nil
}
func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(tokenStr) > 0 {
return tokenStr
}
cookie, err := r.Cookie(global.COOKIE_TOKEN)
if err != nil {
return ""
}
return cookie.Value
}
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
tokenStr := GetTokenFromRequest(db, r)
token, err := GetToken(db, tokenStr)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, errors.New("GetToken: " + err.Error())
}
// does user-agent match the token?
if r.UserAgent() != token.UserAgent {
// invalidate the token
DeleteToken(db, tokenStr)
fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent())
// TODO: log unauthorised activity to the user
return nil, errors.New("User agent mismatch")
}
return GetAccountByToken(db, tokenStr)
}
func CreateAccount(db *sqlx.DB, account *model.Account) error { func CreateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec( err := db.Get(
"INSERT INTO account (username, password, email, avatar_url) " + &account.ID,
"VALUES ($1, $2, $3, $4)", "INSERT INTO account (username, password, email, avatar_url) " +
account.Username, "VALUES ($1, $2, $3, $4) " +
account.Password, "RETURNING id",
account.Email, account.Username,
account.AvatarURL) account.Password,
account.Email,
account.AvatarURL,
)
return err return err
} }
func UpdateAccount(db *sqlx.DB, account *model.Account) error { func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec( _, err := db.Exec(
"UPDATE account " + "UPDATE account " +
"SET username=$2, password=$3, email=$4, avatar_url=$5) " + "SET username=$2, password=$3, email=$4, avatar_url=$5) " +
"WHERE id=$1", "WHERE id=$1",
account.ID, account.ID,
account.Username, account.Username,
account.Password, account.Password,
account.Email, account.Email,
account.AvatarURL) account.AvatarURL,
)
return err return err
} }
func DeleteAccount(db *sqlx.DB, accountID string) error { func DeleteAccount(db *sqlx.DB, username string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) _, err := db.Exec("DELETE FROM account WHERE username=$1", username)
return err return err
} }
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
func GenerateInviteCode(length int) []byte {
code := []byte{}
for i := 0; i < length; i++ {
code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)])
}
return code
}

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
}

86
controller/migrator.go Normal file
View file

@ -0,0 +1,86 @@
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
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)
}
}

View file

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

61
controller/token.go Normal file
View file

@ -0,0 +1,61 @@
package controller
import (
"time"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
const TOKEN_LEN = 32
func CreateToken(db *sqlx.DB, accountID string, userAgent string) (*model.Token, error) {
tokenString := GenerateAlnumString(TOKEN_LEN)
token := model.Token{
Token: string(tokenString),
AccountID: accountID,
UserAgent: userAgent,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24),
}
_, err := db.Exec("INSERT INTO token " +
"(token, account, user_agent, created_at, expires_at) VALUES " +
"($1, $2, $3, $4, $5)",
token.Token,
token.AccountID,
token.UserAgent,
token.CreatedAt,
token.ExpiresAt,
)
if err != nil {
return nil, err
}
return &token, nil
}
func GetToken(db *sqlx.DB, token_str string) (*model.Token, error) {
token := model.Token{}
err := db.Get(&token, "SELECT * FROM token WHERE token=$1", token_str)
return &token, err
}
func GetAllTokensForAccount(db *sqlx.DB, accountID string) ([]model.Token, error) {
tokens := []model.Token{}
err := db.Select(&tokens, "SELECT * FROM token WHERE account=$1 AND expires_at>current_timestamp", accountID)
return tokens, err
}
func DeleteAllTokensForAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM token WHERE account=$1", accountID)
return err
}
func DeleteToken(db *sqlx.DB, token string) error {
_, err := db.Exec("DELETE FROM token WHERE token=$1", token)
return err
}

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
@ -57,13 +56,13 @@ var Config = func() config {
err = toml.Unmarshal([]byte(data), &config) err = toml.Unmarshal([]byte(data), &config)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error()) fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
os.Exit(1) os.Exit(1)
} }
err = handleConfigOverrides(&config) err = handleConfigOverrides(&config)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error()) fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -92,30 +91,4 @@ func handleConfigOverrides(config *config) error {
return nil 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 var DB *sqlx.DB

3
global/const.go Normal file
View file

@ -0,0 +1,3 @@
package global
const COOKIE_TOKEN string = "AM_TOKEN"

View file

@ -58,12 +58,12 @@ func DefaultHeaders(next http.Handler) http.Handler {
type LoggingResponseWriter struct { type LoggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
Code int Status int
} }
func (lrw *LoggingResponseWriter) WriteHeader(code int) { func (lrw *LoggingResponseWriter) WriteHeader(status int) {
lrw.Code = code lrw.Status = status
lrw.ResponseWriter.WriteHeader(code) lrw.ResponseWriter.WriteHeader(status)
} }
func HTTPLog(next http.Handler) http.Handler { func HTTPLog(next http.Handler) http.Handler {
@ -81,22 +81,21 @@ func HTTPLog(next http.Handler) http.Handler {
elapsed = strconv.Itoa(difference) elapsed = strconv.Itoa(difference)
} }
codeColour := colour.Reset statusColour := colour.Reset
if lrw.Code - 600 <= 0 { codeColour = colour.Red } if lrw.Status - 600 <= 0 { statusColour = colour.Red }
if lrw.Code - 500 <= 0 { codeColour = colour.Yellow } if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
if lrw.Code - 400 <= 0 { codeColour = colour.White } if lrw.Status - 400 <= 0 { statusColour = colour.White }
if lrw.Code - 300 <= 0 { codeColour = colour.Green } if lrw.Status - 300 <= 0 { statusColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate), after.Format(time.UnixDate),
r.Method, r.Method,
r.URL.Path, r.URL.Path,
codeColour, statusColour,
lrw.Code, lrw.Status,
colour.Reset, colour.Reset,
elapsed, elapsed,
r.Header["User-Agent"][0]) r.Header["User-Agent"][0])
}) })
} }

247
main.go
View file

@ -7,22 +7,28 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"arimelody-web/admin" "arimelody-web/admin"
"arimelody-web/api" "arimelody-web/api"
"arimelody-web/global"
"arimelody-web/view"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/templates" "arimelody-web/templates"
"arimelody-web/view"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080 const DEFAULT_PORT int64 = 8080
func main() { func main() {
fmt.Printf("made with <3 by ari melody\n\n")
// initialise database connection // initialise database connection
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } 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_NAME"); env != "" { global.Config.DB.Name = env }
@ -65,39 +71,107 @@ func main() {
global.DB.SetMaxIdleConns(10) global.DB.SetMaxIdleConns(10)
defer global.DB.Close() defer global.DB.Close()
_, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP") // handle command arguments
if err != nil { if len(os.Args) > 1 {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) arg := os.Args[1]
os.Exit(1)
switch arg {
case "createInvite":
fmt.Printf("Creating invite...\n")
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err)
os.Exit(1)
}
fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code)
return
case "purgeInvites":
fmt.Printf("Deleting all invites...\n")
err := controller.DeleteAllInvites(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err)
os.Exit(1)
}
fmt.Printf("Invites deleted successfully.\n")
return
case "deleteAccount":
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n")
os.Exit(1)
}
username := os.Args[2]
fmt.Printf("Deleting account \"%s\"...\n", username)
account, err := controller.GetAccount(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error())
os.Exit(1)
}
if account == nil {
fmt.Fprintf(os.Stderr, "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(global.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
return
}
fmt.Printf(
"Available commands:\n\n" +
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
"purgeInvites:\n\tDeletes all available invite codes.\n" +
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
)
return
} }
accountsCount := 0 // handle DB migrations
global.DB.Get(&accountsCount, "SELECT count(*) FROM account") controller.CheckDBVersionAndMigrate(global.DB)
if accountsCount == 0 {
code := controller.GenerateInviteCode(8)
tx, err := global.DB.Begin() // initial invite code
if err != nil { accountsCount := 0
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err) err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
os.Exit(1) if err != nil { panic(err) }
} if accountsCount == 0 {
_, err = tx.Exec("DELETE FROM invite") _, err := global.DB.Exec("DELETE FROM invite")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
os.Exit(1) os.Exit(1)
} }
_, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute))
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
os.Exit(1)
}
err = tx.Commit()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)") fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl)
}
// delete expired invites
err = controller.DeleteExpiredInvites(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
os.Exit(1)
} }
// start the web server! // start the web server!
@ -109,131 +183,6 @@ func main() {
)) ))
} }
func initDB(driverName string, dataSourceName string) (*sqlx.DB, error) {
db, err := sqlx.Connect(driverName, dataSourceName)
if err != nil { return nil, err }
// ensure tables exist
// account
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS account (" +
"id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " +
"username text NOT NULL UNIQUE, " +
"password text NOT NULL, " +
"email text, " +
"avatar_url text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create account table: %s", err.Error())) }
// privilege
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS privilege (" +
"account uuid NOT NULL, " +
"privilege text NOT NULL, " +
"CONSTRAINT privilege_pk PRIMARY KEY (account, privilege), " +
"CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create privilege table: %s", err.Error())) }
// totp
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS totp (" +
"account uuid NOT NULL, " +
"name text NOT NULL, " +
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
"CONSTRAINT totp_pk PRIMARY KEY (account, name), " +
"CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) }
// invites
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS invite (" +
"code text NOT NULL PRIMARY KEY, " +
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
"expires_at TIMESTAMP NOT NULL)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) }
// artist
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS artist (" +
"id character varying(64) PRIMARY KEY, " +
"name text NOT NULL, " +
"website text, " +
"avatar text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create artist table: %s", err.Error())) }
// musicrelease
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS musicrelease (" +
"id character varying(64) PRIMARY KEY, " +
"visible bool DEFAULT false, " +
"title text NOT NULL, " +
"description text, " +
"type text, " +
"release_date TIMESTAMP NOT NULL, " +
"artwork text, " +
"buyname text, " +
"buylink text, " +
"copyright text, " +
"copyrightURL text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicrelease table: %s", err.Error())) }
// musiclink
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musiclink (" +
"release character varying(64) NOT NULL, " +
"name text NOT NULL, " +
"url text NOT NULL, " +
"CONSTRAINT musiclink_pk PRIMARY KEY (release, name), " +
"CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiclink table: %s", err.Error())) }
// musiccredit
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musiccredit (" +
"release character varying(64) NOT NULL, " +
"artist character varying(64) NOT NULL, " +
"role text NOT NULL, " +
"is_primary boolean DEFAULT false, " +
"CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist), " +
"CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " +
"CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiccredit table: %s", err.Error())) }
// musictrack
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musictrack (" +
"id uuid DEFAULT gen_random_uuid() PRIMARY KEY, " +
"title text NOT NULL, " +
"description text, " +
"lyrics text, " +
"preview_url text)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musictrack table: %s", err.Error())) }
// musicreleasetrack
_, err = db.Exec(
"CREATE TABLE IF NOT EXISTS public.musicreleasetrack (" +
"release character varying(64) NOT NULL, " +
"track uuid NOT NULL, " +
"number integer NOT NULL, " +
"CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track), " +
"CONSTRAINT musicreleasetrack_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " +
"CONSTRAINT musicreleasetrack_artist_fk FOREIGN KEY (track) REFERENCES track(id) ON DELETE CASCADE)",
)
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicreleasetrack table: %s", err.Error())) }
// TODO: automatic database migration
return db, nil
}
func createServeMux() *http.ServeMux { func createServeMux() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()

View file

@ -1,27 +1,16 @@
package model package model
import (
"time"
)
type ( type (
Account struct { Account struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password []byte `json:"password" db:"password"` Password string `json:"password" db:"password"`
Email string `json:"email" db:"email"` Email string `json:"email" db:"email"`
AvatarURL string `json:"avatar_url" db:"avatar_url"` AvatarURL string `json:"avatar_url" db:"avatar_url"`
Privileges []AccountPrivilege `json:"privileges"` Privileges []AccountPrivilege `json:"privileges"`
} }
AccountPrivilege string AccountPrivilege string
Invite struct {
Code string `db:"code"`
CreatedByID string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}
) )
const ( const (

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"`
}

11
model/token.go Normal file
View file

@ -0,0 +1,11 @@
package model
import "time"
type Token struct {
Token string `json:"token" db:"token"`
AccountID string `json:"-" db:"account"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
}

View file

@ -1,8 +1,16 @@
CREATE SCHEMA arimelody AUTHORIZATION arimelody; CREATE SCHEMA arimelody;
-- Schema verison
CREATE TABLE arimelody.schema_version (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
);
-- --
-- Acounts -- Tables
-- --
-- Accounts
CREATE TABLE arimelody.account ( CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(), id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE, username text NOT NULL UNIQUE,
@ -12,18 +20,14 @@ CREATE TABLE arimelody.account (
); );
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
--
-- Privilege -- Privilege
--
CREATE TABLE arimelody.privilege ( CREATE TABLE arimelody.privilege (
account uuid NOT NULL, account uuid NOT NULL,
privilege text NOT NULL privilege text NOT NULL
); );
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege);
--
-- TOTP -- TOTP
--
CREATE TABLE arimelody.totp ( CREATE TABLE arimelody.totp (
account uuid NOT NULL, account uuid NOT NULL,
name text NOT NULL, name text NOT NULL,
@ -31,9 +35,7 @@ CREATE TABLE arimelody.totp (
); );
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
--
-- Invites -- Invites
--
CREATE TABLE arimelody.invite ( CREATE TABLE arimelody.invite (
code text NOT NULL, code text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -41,9 +43,18 @@ CREATE TABLE arimelody.invite (
); );
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
-- -- Tokens
CREATE TABLE arimelody.token (
token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL
);
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
-- Artists (should be applicable to all art) -- Artists (should be applicable to all art)
--
CREATE TABLE arimelody.artist ( CREATE TABLE arimelody.artist (
id character varying(64), id character varying(64),
name text NOT NULL, name text NOT NULL,
@ -52,9 +63,7 @@ CREATE TABLE arimelody.artist (
); );
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
--
-- Music releases -- Music releases
--
CREATE TABLE arimelody.musicrelease ( CREATE TABLE arimelody.musicrelease (
id character varying(64) NOT NULL, id character varying(64) NOT NULL,
visible bool DEFAULT false, visible bool DEFAULT false,
@ -70,9 +79,7 @@ CREATE TABLE arimelody.musicrelease (
); );
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
--
-- Music links (external platform links under a release) -- Music links (external platform links under a release)
--
CREATE TABLE arimelody.musiclink ( CREATE TABLE arimelody.musiclink (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
name text NOT NULL, name text NOT NULL,
@ -80,9 +87,7 @@ CREATE TABLE arimelody.musiclink (
); );
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
--
-- Music credits (artist credits under a release) -- Music credits (artist credits under a release)
--
CREATE TABLE arimelody.musiccredit ( CREATE TABLE arimelody.musiccredit (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
artist character varying(64) NOT NULL, artist character varying(64) NOT NULL,
@ -91,9 +96,7 @@ CREATE TABLE arimelody.musiccredit (
); );
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
--
-- Music tracks (tracks under a release) -- Music tracks (tracks under a release)
--
CREATE TABLE arimelody.musictrack ( CREATE TABLE arimelody.musictrack (
id uuid DEFAULT gen_random_uuid(), id uuid DEFAULT gen_random_uuid(),
title text NOT NULL, title text NOT NULL,
@ -103,9 +106,7 @@ CREATE TABLE arimelody.musictrack (
); );
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
--
-- Music release/track pairs -- Music release/track pairs
--
CREATE TABLE arimelody.musicreleasetrack ( CREATE TABLE arimelody.musicreleasetrack (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
track uuid NOT NULL, track uuid NOT NULL,
@ -113,11 +114,15 @@ CREATE TABLE arimelody.musicreleasetrack (
); );
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track); ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
-- --
-- Foreign keys -- Foreign keys
-- --
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_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.totp ADD CONSTRAINT totp_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.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_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.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;

View file

@ -0,0 +1,65 @@
--
-- Migration
--
-- Move existing tables to new schema
ALTER TABLE public.artist SET SCHEMA arimelody;
ALTER TABLE public.musicrelease SET SCHEMA arimelody;
ALTER TABLE public.musiclink SET SCHEMA arimelody;
ALTER TABLE public.musiccredit SET SCHEMA arimelody;
ALTER TABLE public.musictrack SET SCHEMA arimelody;
ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody;
--
-- New items
--
-- Acounts
CREATE TABLE arimelody.account (
id uuid DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
password text NOT NULL,
email text,
avatar_url text
);
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);
-- TOTP
CREATE TABLE arimelody.totp (
account uuid NOT NULL,
name text NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
-- 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);
-- Tokens
CREATE TABLE arimelody.token (
token TEXT,
account UUID NOT NULL,
user_agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
expires_at TIMESTAMP DEFAULT NULL
);
ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token);
-- Foreign keys
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_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.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;

View file

@ -3,8 +3,8 @@ package view
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"arimelody-web/admin"
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/global" "arimelody-web/global"
"arimelody-web/model" "arimelody-web/model"
@ -59,15 +59,28 @@ func ServeCatalog() http.Handler {
func ServeGateway(release *model.Release) http.Handler { func ServeGateway(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases // only allow authorised users to view hidden releases
authorised := admin.GetSession(r) != nil privileged := false
if !authorised && !release.Visible { if !release.Visible {
http.NotFound(w, r) account, err := controller.GetAccountByRequest(global.DB, r)
return if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if account != nil {
// TODO: check privilege on release
privileged = true
}
if !privileged {
http.NotFound(w, r)
return
}
} }
response := *release response := *release
if authorised || release.IsReleased() { if release.IsReleased() || privileged {
response.Tracks = release.Tracks response.Tracks = release.Tracks
response.Credits = release.Credits response.Credits = release.Credits
response.Links = release.Links response.Links = release.Links