Compare commits

..

No commits in common. "570cdf6ce2249b8060bdc67bec9b685fce25c938" and "35d3ce5c5db1bf129fa727794b6d398e0f8aa54a" have entirely different histories.

40 changed files with 561 additions and 1228 deletions

View file

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

1
.gitignore vendored
View file

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

View file

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

View file

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

48
admin/admin.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,65 +10,6 @@ p a {
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;
}
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>
{{end}}
@ -76,28 +17,15 @@ button:active {
<main>
{{if .Token}}
<meta http-equiv="refresh" content="0;url=/admin/" />
<meta http-equiv="refresh" content="5;url=/admin/" />
<p>
Logged in successfully.
You should be redirected to <a href="/admin">/admin</a> soon.
You should be redirected to <a href="/admin">/admin</a> in 5 seconds.
</p>
{{else}}
<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>
<p>Log in with <a href="{{.DiscordURI}}" class="discord">Discord</a>.</p>
{{end}}
</main>

View file

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

View file

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

View file

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

View file

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

View file

@ -28,7 +28,7 @@ func ServeAllTracks() http.Handler {
var dbTracks = []*model.Track{}
dbTracks, err := controller.GetAllTracks(global.DB)
if err != nil {
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -44,7 +44,7 @@ func ServeAllTracks() http.Handler {
encoder.SetIndent("", "\t")
err = encoder.Encode(tracks)
if err != nil {
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err)
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) {
dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false)
if err != nil {
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
@ -68,7 +68,7 @@ func ServeTrack(track *model.Track) http.Handler {
encoder.SetIndent("", "\t")
err = encoder.Encode(Track{ track, releases })
if err != nil {
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -95,7 +95,7 @@ func CreateTrack() http.Handler {
id, err := controller.CreateTrack(global.DB, &track)
if err != nil {
fmt.Printf("WARN: Failed to create track: %s\n", err)
fmt.Printf("FATAL: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

View file

@ -1,12 +1,8 @@
package controller
import (
"arimelody-web/global"
"arimelody-web/model"
"errors"
"fmt"
"net/http"
"strings"
"math/rand"
"github.com/jmoiron/sqlx"
)
@ -16,9 +12,6 @@ func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
@ -30,98 +23,49 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, nil
}
return nil, err
}
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 {
err := db.Get(
&account.ID,
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4) " +
"RETURNING id",
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
_, err := db.Exec(
"INSERT INTO account (username, password, email, avatar_url) " +
"VALUES ($1, $2, $3, $4)",
account.Username,
account.Password,
account.Email,
account.AvatarURL)
return err
}
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
_, err := db.Exec(
"UPDATE account " +
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
"WHERE id=$1",
account.ID,
account.Username,
account.Password,
account.Email,
account.AvatarURL,
)
"UPDATE account " +
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
"WHERE id=$1",
account.ID,
account.Username,
account.Password,
account.Email,
account.AvatarURL)
return err
}
func DeleteAccount(db *sqlx.DB, username string) error {
_, err := db.Exec("DELETE FROM account WHERE username=$1", username)
func DeleteAccount(db *sqlx.DB, accountID string) error {
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
return err
}
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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/pelletier/go-toml/v2"
@ -56,13 +57,13 @@ var Config = func() config {
err = toml.Unmarshal([]byte(data), &config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error())
os.Exit(1)
}
err = handleConfigOverrides(&config)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error())
os.Exit(1)
}
@ -91,4 +92,30 @@ func handleConfigOverrides(config *config) error {
return nil
}
var Args = func() map[string]string {
args := map[string]string{}
index := 0
for index < len(os.Args[1:]) {
arg := os.Args[index + 1]
if !strings.HasPrefix(arg, "-") {
fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg)
os.Exit(1)
}
if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") {
args[arg[1:]] = "true"
index += 1
continue
}
val := os.Args[index + 2]
args[arg[1:]] = val
// fmt.Printf("%s: %s\n", arg[1:], val)
index += 2
}
return args
}()
var DB *sqlx.DB

View file

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

View file

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

245
main.go
View file

@ -7,28 +7,22 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/templates"
"arimelody-web/view"
"arimelody-web/controller"
"arimelody-web/templates"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// used for database migrations
const DB_VERSION = 1
const DEFAULT_PORT int64 = 8080
func main() {
fmt.Printf("made with <3 by ari melody\n\n")
// initialise database connection
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env }
if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env }
@ -71,107 +65,39 @@ func main() {
global.DB.SetMaxIdleConns(10)
defer global.DB.Close()
// handle command arguments
if len(os.Args) > 1 {
arg := os.Args[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
_, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
os.Exit(1)
}
// handle DB migrations
controller.CheckDBVersionAndMigrate(global.DB)
// initial invite code
accountsCount := 0
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if err != nil { panic(err) }
global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
if accountsCount == 0 {
_, err := global.DB.Exec("DELETE FROM invite")
code := controller.GenerateInviteCode(8)
tx, err := global.DB.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err)
os.Exit(1)
}
_, err = tx.Exec("DELETE FROM invite")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
os.Exit(1)
}
invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24)
_, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute))
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %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)
}
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)
fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)")
}
// start the web server!
@ -183,6 +109,131 @@ 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 {
mux := http.NewServeMux()

View file

@ -1,16 +1,27 @@
package model
import (
"time"
)
type (
Account struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"`
Password []byte `json:"password" db:"password"`
Email string `json:"email" db:"email"`
AvatarURL string `json:"avatar_url" db:"avatar_url"`
Privileges []AccountPrivilege `json:"privileges"`
}
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 (

View file

@ -1,10 +0,0 @@
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"`
}

View file

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

View file

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

View file

@ -1,65 +0,0 @@
--
-- 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 (
"fmt"
"net/http"
"os"
"arimelody-web/admin"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model"
@ -59,28 +59,15 @@ func ServeCatalog() http.Handler {
func ServeGateway(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases
privileged := false
if !release.Visible {
account, err := controller.GetAccountByRequest(global.DB, r)
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
}
authorised := admin.GetSession(r) != nil
if !authorised && !release.Visible {
http.NotFound(w, r)
return
}
response := *release
if release.IsReleased() || privileged {
if authorised || release.IsReleased() {
response.Tracks = release.Tracks
response.Credits = release.Credits
response.Links = release.Links