Compare commits
4 commits
35d3ce5c5d
...
570cdf6ce2
Author | SHA1 | Date | |
---|---|---|---|
570cdf6ce2 | |||
5566a795da | |||
d5f1fcb5e0 | |||
e2ec731109 |
40 changed files with 1230 additions and 563 deletions
|
@ -6,7 +6,6 @@
|
|||
uploads/
|
||||
test/
|
||||
tmp/
|
||||
db/
|
||||
res/
|
||||
docker-compose.yml
|
||||
docker-compose-test.yml
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -7,3 +7,4 @@ uploads/
|
|||
docker-compose*.yml
|
||||
!docker-compose.example.yml
|
||||
config*.toml
|
||||
>>>>>>> dev
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
16
README.md
16
README.md
|
@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff!
|
|||
## running
|
||||
|
||||
the server should be run once to generate a default `config.toml` file.
|
||||
configure as needed. note that a valid DB connection is required, and the admin
|
||||
panel will be disabled without valid discord app credentials (this can however
|
||||
be bypassed by running the server with `-adminBypass`).
|
||||
configure as needed. a valid DB connection is required to run this website.
|
||||
if no admin users exist, an invite code will be provided. invite codes are
|
||||
the only way to create admin accounts at this time.
|
||||
|
||||
the configuration may be overridden using environment variables in the format
|
||||
`ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may
|
||||
|
@ -32,6 +32,16 @@ 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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -32,12 +32,19 @@ func serveArtist() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
*model.Artist
|
||||
Credits []*model.Credit
|
||||
type ArtistResponse struct {
|
||||
Account *model.Account
|
||||
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 {
|
||||
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
|
@ -52,7 +52,6 @@
|
|||
makeMagicList(creditList, ".credit");
|
||||
|
||||
function rigCredit(el) {
|
||||
console.log(el);
|
||||
const artistID = el.dataset.artist;
|
||||
const deleteBtn = el.querySelector("a.delete");
|
||||
|
||||
|
|
|
@ -12,12 +12,12 @@
|
|||
|
||||
<form action="/api/v1/music/{{.ID}}/tracks">
|
||||
<ul>
|
||||
{{range .Tracks}}
|
||||
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true">
|
||||
{{range $i, $track := .Tracks}}
|
||||
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
|
||||
<div>
|
||||
<p class="track-name">
|
||||
<span class="track-number">{{.Number}}</span>
|
||||
{{.Title}}
|
||||
<span class="track-number">{{$track.Add $i 1}}</span>
|
||||
{{$track.Title}}
|
||||
</p>
|
||||
<a class="delete">Delete</a>
|
||||
</div>
|
||||
|
|
339
admin/http.go
339
admin/http.go
|
@ -8,17 +8,17 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"encoding/json"
|
||||
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type loginData struct {
|
||||
DiscordURI string
|
||||
type TemplateData struct {
|
||||
Account *model.Account
|
||||
Token string
|
||||
}
|
||||
|
||||
|
@ -26,57 +26,64 @@ func Handler() http.Handler {
|
|||
mux := http.NewServeMux()
|
||||
|
||||
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("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease())))
|
||||
mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist())))
|
||||
mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack())))
|
||||
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("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := GetSession(r)
|
||||
if session == nil {
|
||||
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 {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull releases: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
artists, err := controller.GetAllArtists(global.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull artists: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tracks, err := controller.GetOrphanTracks(global.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type IndexData struct {
|
||||
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.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)
|
||||
return
|
||||
}
|
||||
|
@ -85,76 +92,43 @@ func Handler() http.Handler {
|
|||
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) {
|
||||
session := GetSession(r)
|
||||
if session == nil {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), "session", session)
|
||||
ctx := context.WithValue(r.Context(), "account", account)
|
||||
|
||||
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 {
|
||||
err := pages["login"].Execute(w, nil)
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -166,41 +140,53 @@ func LoginHandler() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
|
||||
data := LoginRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&data)
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password))
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: check TOTP
|
||||
|
||||
// login success!
|
||||
session := createSession(account.ID, time.Now().Add(24 * time.Hour))
|
||||
sessions = append(sessions, &session)
|
||||
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
|
||||
}
|
||||
|
||||
cookie := http.Cookie{}
|
||||
cookie.Name = "token"
|
||||
cookie.Value = session.Token
|
||||
cookie.Expires = time.Now().Add(24 * time.Hour)
|
||||
cookie.Name = global.COOKIE_TOKEN
|
||||
cookie.Value = token.Token
|
||||
cookie.Expires = token.ExpiresAt
|
||||
if strings.HasPrefix(global.Config.BaseUrl, "https") {
|
||||
cookie.Secure = true
|
||||
}
|
||||
|
@ -208,7 +194,10 @@ func LoginHandler() http.Handler {
|
|||
cookie.Path = "/"
|
||||
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 {
|
||||
fmt.Printf("Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -224,22 +213,168 @@ func LogoutHandler() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
session := GetSession(r)
|
||||
tokenStr := controller.GetTokenFromRequest(global.DB, r)
|
||||
|
||||
// remove this session from the list
|
||||
sessions = func (token string) []*Session {
|
||||
new_sessions := []*Session{}
|
||||
for _, session := range sessions {
|
||||
if session.Token != token {
|
||||
new_sessions = append(new_sessions, session)
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ 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") {
|
||||
|
@ -26,12 +28,6 @@ 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":
|
||||
|
@ -60,7 +56,15 @@ func serveRelease() http.Handler {
|
|||
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 {
|
||||
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
|
@ -43,7 +43,7 @@ nav .title {
|
|||
|
||||
color: inherit;
|
||||
}
|
||||
nav a {
|
||||
.nav-item {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
|
||||
|
@ -53,16 +53,17 @@ nav a {
|
|||
display: flex;
|
||||
|
||||
line-height: 2em;
|
||||
text-decoration: none;
|
||||
|
||||
color: inherit;
|
||||
}
|
||||
nav a:hover {
|
||||
.nav-item:hover {
|
||||
background: #00000010;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
nav #logout {
|
||||
margin-left: auto;
|
||||
/* margin-left: auto; */
|
||||
}
|
||||
|
||||
main {
|
||||
|
@ -114,6 +115,10 @@ a img.icon {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.flex-fill {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 520px) {
|
||||
body {
|
||||
font-size: 12px;
|
||||
|
|
|
@ -18,6 +18,11 @@ 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"),
|
||||
|
|
|
@ -32,12 +32,19 @@ func serveTrack() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
*model.Track
|
||||
type TrackResponse struct {
|
||||
Account *model.Account
|
||||
Track *model.Track
|
||||
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 {
|
||||
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
102
admin/views/create-account.html
Normal file
102
admin/views/create-account.html
Normal 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}}
|
|
@ -1,5 +1,6 @@
|
|||
{{define "head"}}
|
||||
<title>Editing {{.Name}} - ari melody 💫</title>
|
||||
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/edit-artist.css">
|
||||
{{end}}
|
||||
|
@ -8,20 +9,20 @@
|
|||
<main>
|
||||
<h1>Editing Artist</h1>
|
||||
|
||||
<div id="artist" data-id="{{.ID}}">
|
||||
<div id="artist" data-id="{{.Artist.ID}}">
|
||||
<div class="artist-avatar">
|
||||
<img src="{{.Avatar}}" alt="" width="256" loading="lazy" id="avatar">
|
||||
<img src="{{.Artist.Avatar}}" alt="" width="256" loading="lazy" id="avatar">
|
||||
<input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
|
||||
<button id="remove-avatar">Remove</button>
|
||||
</div>
|
||||
<div class="artist-info">
|
||||
<p class="attribute-header">Name</p>
|
||||
<h2 class="artist-name">
|
||||
<input type="text" id="name" name="artist-name" value="{{.Name}}">
|
||||
<input type="text" id="name" name="artist-name" value="{{.Artist.Name}}">
|
||||
</h2>
|
||||
|
||||
<p class="attribute-header">Website</p>
|
||||
<input type="text" id="website" name="website" value="{{.Website}}">
|
||||
<input type="text" id="website" name="website" value="{{.Artist.Website}}">
|
||||
|
||||
<div class="artist-actions">
|
||||
<button type="submit" class="save" id="save" disabled>Save</button>
|
||||
|
@ -36,13 +37,13 @@
|
|||
{{if .Credits}}
|
||||
{{range .Credits}}
|
||||
<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">
|
||||
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
||||
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
|
||||
<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>
|
||||
<p class="artist-role">
|
||||
Role: {{.Role}}
|
||||
{{if .Primary}}
|
||||
Role: {{.Artist.Role}}
|
||||
{{if .Artist.Primary}}
|
||||
<small>(Primary)</small>
|
||||
{{end}}
|
||||
</p>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{define "head"}}
|
||||
<title>Editing {{.Title}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.GetArtwork}}" type="image/x-icon">
|
||||
<title>Editing {{.Release.Title}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/edit-release.css">
|
||||
{{end}}
|
||||
|
@ -8,21 +8,21 @@
|
|||
{{define "content"}}
|
||||
<main>
|
||||
|
||||
<div id="release" data-id="{{.ID}}">
|
||||
<div id="release" data-id="{{.Release.ID}}">
|
||||
<div class="release-artwork">
|
||||
<img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
|
||||
<img src="{{.Release.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
|
||||
<input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
|
||||
<button id="remove-artwork">Remove</button>
|
||||
</div>
|
||||
<div class="release-info">
|
||||
<h1 class="release-title">
|
||||
<input type="text" id="title" name="Title" value="{{.Title}}" autocomplete="on">
|
||||
<input type="text" id="title" name="Title" value="{{.Release.Title}}" autocomplete="on">
|
||||
</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>
|
||||
{{$t := .ReleaseType}}
|
||||
{{$t := .Release.ReleaseType}}
|
||||
<select name="Type" id="type">
|
||||
<option value="single" {{if eq $t "single"}}selected{{end}}>
|
||||
Single
|
||||
|
@ -44,71 +44,71 @@
|
|||
<td>
|
||||
<textarea
|
||||
name="Description"
|
||||
value="{{.Description}}"
|
||||
value="{{.Release.Description}}"
|
||||
placeholder="No description provided."
|
||||
rows="3"
|
||||
id="description"
|
||||
>{{.Description}}</textarea>
|
||||
>{{.Release.Description}}</textarea>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Release Date</td>
|
||||
<td>
|
||||
<input type="datetime-local" name="release-date" id="release-date" value="{{.TextReleaseDate}}">
|
||||
<input type="datetime-local" name="release-date" id="release-date" value="{{.Release.TextReleaseDate}}">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Buy Name</td>
|
||||
<td>
|
||||
<input type="text" name="buyname" id="buyname" value="{{.Buyname}}" autocomplete="on">
|
||||
<input type="text" name="buyname" id="buyname" value="{{.Release.Buyname}}" autocomplete="on">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Buy Link</td>
|
||||
<td>
|
||||
<input type="text" name="buylink" id="buylink" value="{{.Buylink}}" autocomplete="on">
|
||||
<input type="text" name="buylink" id="buylink" value="{{.Release.Buylink}}" autocomplete="on">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copyright</td>
|
||||
<td>
|
||||
<input type="text" name="copyright" id="copyright" value="{{.Copyright}}" autocomplete="on">
|
||||
<input type="text" name="copyright" id="copyright" value="{{.Release.Copyright}}" autocomplete="on">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copyright URL</td>
|
||||
<td>
|
||||
<input type="text" name="copyright-url" id="copyright-url" value="{{.CopyrightURL}}" autocomplete="on">
|
||||
<input type="text" name="copyright-url" id="copyright-url" value="{{.Release.CopyrightURL}}" autocomplete="on">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Visible</td>
|
||||
<td>
|
||||
<select name="Visibility" id="visibility">
|
||||
<option value="true" {{if .Visible}}selected{{end}}>True</option>
|
||||
<option value="false" {{if not .Visible}}selected{{end}}>False</option>
|
||||
<option value="true" {{if .Release.Visible}}selected{{end}}>True</option>
|
||||
<option value="false" {{if not .Release.Visible}}selected{{end}}>False</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="release-actions">
|
||||
<a href="/music/{{.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
|
||||
<a href="/music/{{.Release.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
|
||||
<button type="submit" class="save" id="save" disabled>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Credits ({{len .Credits}})</h2>
|
||||
<h2>Credits ({{len .Release.Credits}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/release/{{.ID}}/editcredits"
|
||||
hx-get="/admin/release/{{.ID}}/editcredits"
|
||||
href="/admin/release/{{.Release.ID}}/editcredits"
|
||||
hx-get="/admin/release/{{.Release.ID}}/editcredits"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<div class="card credits">
|
||||
{{range .Credits}}
|
||||
{{range .Release.Credits}}
|
||||
<div class="credit">
|
||||
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||
<div class="credit-info">
|
||||
|
@ -122,37 +122,37 @@
|
|||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Credits}}
|
||||
{{if not .Release.Credits}}
|
||||
<p>There are no credits.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Links ({{len .Links}})</h2>
|
||||
<h2>Links ({{len .Release.Links}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/release/{{.ID}}/editlinks"
|
||||
hx-get="/admin/release/{{.ID}}/editlinks"
|
||||
href="/admin/release/{{.Release.ID}}/editlinks"
|
||||
hx-get="/admin/release/{{.Release.ID}}/editlinks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<div class="card links">
|
||||
{{range .Links}}
|
||||
{{range .Release.Links}}
|
||||
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-title" id="tracks">
|
||||
<h2>Tracklist ({{len .Tracks}})</h2>
|
||||
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/release/{{.ID}}/edittracks"
|
||||
hx-get="/admin/release/{{.ID}}/edittracks"
|
||||
href="/admin/release/{{.Release.ID}}/edittracks"
|
||||
hx-get="/admin/release/{{.Release.ID}}/edittracks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<div class="card tracks">
|
||||
{{range $i, $track := .Tracks}}
|
||||
{{range $i, $track := .Release.Tracks}}
|
||||
<div class="track" data-id="{{$track.ID}}">
|
||||
<h2 class="track-title">
|
||||
<span class="track-number">{{.Add $i 1}}</span>
|
||||
|
|
|
@ -8,30 +8,30 @@
|
|||
<main>
|
||||
<h1>Editing Track</h1>
|
||||
|
||||
<div id="track" data-id="{{.ID}}">
|
||||
<div id="track" data-id="{{.Track.ID}}">
|
||||
<div class="track-info">
|
||||
<p class="attribute-header">Title</p>
|
||||
<h2 class="track-title">
|
||||
<input type="text" id="title" name="Title" value="{{.Title}}">
|
||||
<input type="text" id="title" name="Title" value="{{.Track.Title}}">
|
||||
</h2>
|
||||
|
||||
<p class="attribute-header">Description</p>
|
||||
<textarea
|
||||
name="Description"
|
||||
value="{{.Description}}"
|
||||
value="{{.Track.Description}}"
|
||||
placeholder="No description provided."
|
||||
rows="5"
|
||||
id="description"
|
||||
>{{.Description}}</textarea>
|
||||
>{{.Track.Description}}</textarea>
|
||||
|
||||
<p class="attribute-header">Lyrics</p>
|
||||
<textarea
|
||||
name="Lyrics"
|
||||
value="{{.Lyrics}}"
|
||||
value="{{.Track.Lyrics}}"
|
||||
placeholder="There are no lyrics."
|
||||
rows="5"
|
||||
id="lyrics"
|
||||
>{{.Lyrics}}</textarea>
|
||||
>{{.Track.Lyrics}}</textarea>
|
||||
|
||||
<div class="track-actions">
|
||||
<button type="submit" class="save" id="save" disabled>Save</button>
|
||||
|
|
|
@ -17,9 +17,22 @@
|
|||
<header>
|
||||
<nav>
|
||||
<img src="/img/favicon.png" alt="" class="icon">
|
||||
<a href="/">arimelody.me</a>
|
||||
<a href="/admin">home</a>
|
||||
<a href="/admin/logout" id="logout">log out</a>
|
||||
<div class="nav-item">
|
||||
<a href="/">arimelody.me</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin">home</a>
|
||||
</div>
|
||||
<div class="flex-fill"></div>
|
||||
{{if .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>
|
||||
</header>
|
||||
|
||||
|
|
|
@ -10,6 +10,65 @@ 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}}
|
||||
|
||||
|
@ -17,15 +76,28 @@ a.discord {
|
|||
<main>
|
||||
{{if .Token}}
|
||||
|
||||
<meta http-equiv="refresh" content="5;url=/admin/" />
|
||||
<meta http-equiv="refresh" content="0;url=/admin/" />
|
||||
<p>
|
||||
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>
|
||||
|
||||
{{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}}
|
||||
</main>
|
||||
|
|
|
@ -35,25 +35,35 @@ func handleLogin() http.HandlerFunc {
|
|||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %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)
|
||||
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 {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
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)
|
||||
w.Write([]byte("Logged in successfully. TODO: Session tokens\n"))
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -68,7 +78,7 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
|
||||
credentials := RegisterRequest{}
|
||||
|
@ -79,50 +89,65 @@ func handleAccountRegistration() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// make sure code exists in DB
|
||||
invite := model.Invite{}
|
||||
err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code)
|
||||
invite, err := controller.GetInvite(global.DB, credentials.Invite)
|
||||
if err != nil {
|
||||
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())
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if invite == nil {
|
||||
http.Error(w, "Invalid invite code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(invite.ExpiresAt) {
|
||||
err := controller.DeleteInvite(global.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
http.Error(w, "Invalid invite code", http.StatusBadRequest)
|
||||
_, 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: %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)
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: hashedPassword,
|
||||
Password: string(hashedPassword),
|
||||
Email: credentials.Email,
|
||||
AvatarURL: "/img/default-avatar.png",
|
||||
}
|
||||
err = controller.CreateAccount(global.DB, &account)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
_, 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()) }
|
||||
err = controller.DeleteInvite(global.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte("Account created successfully\n"))
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -151,20 +176,22 @@ func handleDeleteAccount() http.HandlerFunc {
|
|||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password))
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
http.Error(w, "Invalid password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteAccount(global.DB, account.ID)
|
||||
// TODO: check TOTP
|
||||
|
||||
err = controller.DeleteAccount(global.DB, account.Username)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
22
api/api.go
22
api/api.go
|
@ -40,10 +40,10 @@ func Handler() http.Handler {
|
|||
ServeArtist(artist).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// 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:
|
||||
// DELETE /api/v1/artist/{id} (admin)
|
||||
admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, 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.MustAuthorise(CreateArtist()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, 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.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/music/{id} (admin)
|
||||
admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, 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.MustAuthorise(CreateRelease()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, 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.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// 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:
|
||||
// DELETE /api/v1/track/{id} (admin)
|
||||
admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, 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.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/track (admin)
|
||||
admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r)
|
||||
admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
@ -21,7 +20,7 @@ func ServeAllArtists() http.Handler {
|
|||
var artists = []*model.Artist{}
|
||||
artists, err := controller.GetAllArtists(global.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve all artists: %s\n", err)
|
||||
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -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)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err)
|
||||
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -109,7 +113,7 @@ func CreateArtist() http.Handler {
|
|||
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err)
|
||||
fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -122,7 +126,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("FATAL: Failed to update artist: %s\n", err)
|
||||
fmt.Printf("WARN: Failed to update artist: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
@ -157,7 +161,7 @@ func UpdateArtist(artist *model.Artist) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err)
|
||||
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -171,7 +175,7 @@ func DeleteArtist(artist *model.Artist) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err)
|
||||
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -10,7 +10,6 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
@ -19,10 +18,23 @@ 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
|
||||
authorised := admin.GetSession(r) != nil
|
||||
if !authorised && !release.Visible {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
|
@ -53,18 +65,18 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
Links: make(map[string]string),
|
||||
}
|
||||
|
||||
if authorised || release.IsReleased() {
|
||||
if release.IsReleased() || privileged {
|
||||
// get credits
|
||||
credits, err := controller.GetReleaseCredits(global.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, credit := range credits {
|
||||
artist, err := controller.GetArtist(global.DB, credit.Artist.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -79,7 +91,7 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
// get tracks
|
||||
tracks, err := controller.GetReleaseTracks(global.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -94,7 +106,7 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
// get links
|
||||
links, err := controller.GetReleaseLinks(global.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -134,11 +146,24 @@ func ServeCatalog() http.Handler {
|
|||
}
|
||||
|
||||
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 {
|
||||
if !release.Visible && !authorised {
|
||||
continue
|
||||
if !release.Visible {
|
||||
privileged := false
|
||||
if account != nil {
|
||||
// TODO: check privilege on release
|
||||
privileged = true
|
||||
}
|
||||
if !privileged {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
artists := []string{}
|
||||
for _, credit := range release.Credits {
|
||||
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)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -281,7 +306,7 @@ func UpdateRelease(release *model.Release) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -302,7 +327,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -343,7 +368,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -369,7 +394,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -383,7 +408,7 @@ func DeleteRelease(release *model.Release) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
10
api/track.go
10
api/track.go
|
@ -28,7 +28,7 @@ func ServeAllTracks() http.Handler {
|
|||
var dbTracks = []*model.Track{}
|
||||
dbTracks, err := controller.GetAllTracks(global.DB)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err)
|
||||
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
@ -44,7 +44,7 @@ func ServeAllTracks() http.Handler {
|
|||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(tracks)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err)
|
||||
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -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("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
||||
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
@ -68,7 +68,7 @@ func ServeTrack(track *model.Track) http.Handler {
|
|||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(Track{ track, releases })
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err)
|
||||
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
@ -95,7 +95,7 @@ func CreateTrack() http.Handler {
|
|||
|
||||
id, err := controller.CreateTrack(global.DB, &track)
|
||||
if err != nil {
|
||||
fmt.Printf("FATAL: Failed to create track: %s\n", err)
|
||||
fmt.Printf("WARN: Failed to create track: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"math/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
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)
|
||||
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.Exec(
|
||||
"INSERT INTO account (username, password, email, avatar_url) " +
|
||||
"VALUES ($1, $2, $3, $4)",
|
||||
account.Username,
|
||||
account.Password,
|
||||
account.Email,
|
||||
account.AvatarURL)
|
||||
err := db.Get(
|
||||
&account.ID,
|
||||
"INSERT INTO account (username, password, email, avatar_url) " +
|
||||
"VALUES ($1, $2, $3, $4) " +
|
||||
"RETURNING id",
|
||||
account.Username,
|
||||
account.Password,
|
||||
account.Email,
|
||||
account.AvatarURL,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE account " +
|
||||
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
|
||||
"WHERE id=$1",
|
||||
account.ID,
|
||||
account.Username,
|
||||
account.Password,
|
||||
account.Email,
|
||||
account.AvatarURL)
|
||||
"UPDATE account " +
|
||||
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
|
||||
"WHERE id=$1",
|
||||
account.ID,
|
||||
account.Username,
|
||||
account.Password,
|
||||
account.Email,
|
||||
account.AvatarURL,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||
func DeleteAccount(db *sqlx.DB, username string) error {
|
||||
_, err := db.Exec("DELETE FROM account WHERE username=$1", username)
|
||||
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
13
controller/controller.go
Normal 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
67
controller/invite.go
Normal 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
86
controller/migrator.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -223,7 +223,6 @@ func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link)
|
|||
return err
|
||||
}
|
||||
for _, link := range new_links {
|
||||
fmt.Printf("%s: %s\n", link.Name, link.URL)
|
||||
_, err := tx.Exec(
|
||||
"INSERT INTO musiclink "+
|
||||
"(release, name, url) "+
|
||||
|
|
61
controller/token.go
Normal file
61
controller/token.go
Normal 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
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
|
@ -57,13 +56,13 @@ var Config = func() config {
|
|||
|
||||
err = toml.Unmarshal([]byte(data), &config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error())
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = handleConfigOverrides(&config)
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -92,30 +91,4 @@ 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
|
||||
|
|
3
global/const.go
Normal file
3
global/const.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package global
|
||||
|
||||
const COOKIE_TOKEN string = "AM_TOKEN"
|
|
@ -58,12 +58,12 @@ func DefaultHeaders(next http.Handler) http.Handler {
|
|||
|
||||
type LoggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Code int
|
||||
Status int
|
||||
}
|
||||
|
||||
func (lrw *LoggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.Code = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||
lrw.Status = status
|
||||
lrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func HTTPLog(next http.Handler) http.Handler {
|
||||
|
@ -81,22 +81,21 @@ func HTTPLog(next http.Handler) http.Handler {
|
|||
elapsed = strconv.Itoa(difference)
|
||||
}
|
||||
|
||||
codeColour := colour.Reset
|
||||
statusColour := colour.Reset
|
||||
|
||||
if lrw.Code - 600 <= 0 { codeColour = colour.Red }
|
||||
if lrw.Code - 500 <= 0 { codeColour = colour.Yellow }
|
||||
if lrw.Code - 400 <= 0 { codeColour = colour.White }
|
||||
if lrw.Code - 300 <= 0 { codeColour = colour.Green }
|
||||
if lrw.Status - 600 <= 0 { statusColour = colour.Red }
|
||||
if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
|
||||
if lrw.Status - 400 <= 0 { statusColour = colour.White }
|
||||
if lrw.Status - 300 <= 0 { statusColour = colour.Green }
|
||||
|
||||
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
|
||||
after.Format(time.UnixDate),
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
codeColour,
|
||||
lrw.Code,
|
||||
statusColour,
|
||||
lrw.Status,
|
||||
colour.Reset,
|
||||
elapsed,
|
||||
r.Header["User-Agent"][0])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
247
main.go
247
main.go
|
@ -7,22 +7,28 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/api"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/view"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/templates"
|
||||
"arimelody-web/view"
|
||||
|
||||
"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 }
|
||||
|
@ -65,39 +71,107 @@ func main() {
|
|||
global.DB.SetMaxIdleConns(10)
|
||||
defer global.DB.Close()
|
||||
|
||||
_, 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 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
|
||||
}
|
||||
|
||||
accountsCount := 0
|
||||
global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
if accountsCount == 0 {
|
||||
code := controller.GenerateInviteCode(8)
|
||||
// handle DB migrations
|
||||
controller.CheckDBVersionAndMigrate(global.DB)
|
||||
|
||||
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")
|
||||
// initial invite code
|
||||
accountsCount := 0
|
||||
err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
if err != nil { panic(err) }
|
||||
if accountsCount == 0 {
|
||||
_, err := global.DB.Exec("DELETE FROM invite")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, 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 {
|
||||
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)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %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)")
|
||||
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!
|
||||
|
@ -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 {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
|
|
@ -1,27 +1,16 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
Account struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
Password []byte `json:"password" db:"password"`
|
||||
Password string `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 (
|
||||
|
|
10
model/invite.go
Normal file
10
model/invite.go
Normal 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
11
model/token.go
Normal 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"`
|
||||
}
|
|
@ -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 (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE,
|
||||
|
@ -12,18 +20,14 @@ 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,
|
||||
|
@ -31,9 +35,7 @@ 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,
|
||||
|
@ -41,9 +43,18 @@ 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,
|
||||
|
@ -52,9 +63,7 @@ CREATE TABLE arimelody.artist (
|
|||
);
|
||||
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
|
||||
|
||||
--
|
||||
-- Music releases
|
||||
--
|
||||
CREATE TABLE arimelody.musicrelease (
|
||||
id character varying(64) NOT NULL,
|
||||
visible bool DEFAULT false,
|
||||
|
@ -70,9 +79,7 @@ CREATE TABLE arimelody.musicrelease (
|
|||
);
|
||||
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
|
||||
|
||||
--
|
||||
-- Music links (external platform links under a release)
|
||||
--
|
||||
CREATE TABLE arimelody.musiclink (
|
||||
release character varying(64) NOT NULL,
|
||||
name text NOT NULL,
|
||||
|
@ -80,9 +87,7 @@ CREATE TABLE arimelody.musiclink (
|
|||
);
|
||||
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
|
||||
|
||||
--
|
||||
-- Music credits (artist credits under a release)
|
||||
--
|
||||
CREATE TABLE arimelody.musiccredit (
|
||||
release character varying(64) NOT NULL,
|
||||
artist character varying(64) NOT NULL,
|
||||
|
@ -91,9 +96,7 @@ CREATE TABLE arimelody.musiccredit (
|
|||
);
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
|
||||
|
||||
--
|
||||
-- Music tracks (tracks under a release)
|
||||
--
|
||||
CREATE TABLE arimelody.musictrack (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
title text NOT NULL,
|
||||
|
@ -103,9 +106,7 @@ CREATE TABLE arimelody.musictrack (
|
|||
);
|
||||
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
|
||||
|
||||
--
|
||||
-- Music release/track pairs
|
||||
--
|
||||
CREATE TABLE arimelody.musicreleasetrack (
|
||||
release character varying(64) NOT NULL,
|
||||
track uuid NOT NULL,
|
||||
|
@ -113,11 +114,15 @@ 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;
|
65
schema_migration/001-pre-versioning.sql
Normal file
65
schema_migration/001-pre-versioning.sql
Normal 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;
|
|
@ -3,8 +3,8 @@ package view
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
|
@ -59,15 +59,28 @@ 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
|
||||
authorised := admin.GetSession(r) != nil
|
||||
if !authorised && !release.Visible {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
response := *release
|
||||
|
||||
if authorised || release.IsReleased() {
|
||||
if release.IsReleased() || privileged {
|
||||
response.Tracks = release.Tracks
|
||||
response.Credits = release.Credits
|
||||
response.Links = release.Links
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue