merged main, dev, and i guess got accounts working??
i am so good at commit messages :3
|
@ -7,7 +7,7 @@ tmp_dir = "tmp"
|
|||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["admin/static", "public", "uploads", "test", "db"]
|
||||
exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
|
|
|
@ -3,10 +3,11 @@
|
|||
.air.toml/
|
||||
.gitattributes
|
||||
.gitignore
|
||||
uploads/*
|
||||
uploads/
|
||||
test/
|
||||
tmp/
|
||||
res/
|
||||
docker-compose.yml
|
||||
docker-compose-test.yml
|
||||
Dockerfile
|
||||
schema.sql
|
||||
|
|
5
.gitignore
vendored
|
@ -4,4 +4,7 @@ db/
|
|||
tmp/
|
||||
test/
|
||||
uploads/
|
||||
docker-compose-test.yml
|
||||
docker-compose*.yml
|
||||
!docker-compose.example.yml
|
||||
config*.toml
|
||||
>>>>>>> dev
|
||||
|
|
33
README.md
|
@ -4,25 +4,36 @@ home to your local SPACEGIRL! 💫
|
|||
|
||||
---
|
||||
|
||||
built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static) branch, this powerful, server-side rendered version comes complete with live updates, powered by a new database and super handy admin panel!
|
||||
built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static)
|
||||
branch, this powerful, server-side rendered version comes complete with live
|
||||
updates, powered by a new database and handy admin panel!
|
||||
|
||||
the admin panel currently facilitates live updating of my music discography, though i plan to expand it towards art portfolio and blog posts in the future. if all goes well, i'd like to later separate these components into their own library for others to use in their own sites. exciting stuff!
|
||||
the admin panel currently facilitates live updating of my music discography,
|
||||
though i plan to expand it towards art portfolio and blog posts in the future.
|
||||
if all goes well, i'd like to later separate these components into their own
|
||||
library for others to use in their own sites. exciting stuff!
|
||||
|
||||
## build
|
||||
|
||||
easy! just `git clone` this repo and `go build` from the root. `arimelody-web(.exe)` should be generated.
|
||||
- `git clone` this repo, and `cd` into it.
|
||||
- `go build -o arimelody-web .`
|
||||
|
||||
## running
|
||||
|
||||
the webserver depends on some environment variables (don't worry about forgetting some; it'll be sure to bug you about them):
|
||||
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`).
|
||||
|
||||
- `HTTP_DOMAIN`: the domain the webserver will use for generating oauth redirect URIs (default `https://arimelody.me`)
|
||||
- `DISCORD_ADMIN`[^1]: the user ID of your discord account (discord auth is intended to be temporary, and will be replaced with its own auth system later)
|
||||
- `DISCORD_CLIENT`[^1]: the client ID of your discord OAuth application.
|
||||
- `DISCORD_SECRET`[^1]: the client secret of your discord OAuth application.
|
||||
the configuration may be overridden using environment variables in the format
|
||||
`ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may
|
||||
be overridden with `ARIMELODY_DB_HOST`.
|
||||
|
||||
[^1]: not required, but the admin panel will be **disabled** if these are not provided.
|
||||
the location of the configuration file can also be overridden with
|
||||
`ARIMELODY_CONFIG`.
|
||||
|
||||
the webserver requires a database to run. in this case, postgres.
|
||||
## database
|
||||
|
||||
the [docker compose script](docker-compose.yml) contains the basic requirements to get you up and running, though it does not currently initialise the schema on first run. you'll need to `docker compose exec -it arimelody.me-db-1` to access the database container while it's running, run `psql -U arimelody` to get a postgres shell, and copy/paste the contents of [schema.sql](schema.sql) to initialise the database. i'll build an automated initialisation script later ;p
|
||||
the server requires a postgres database to run. you can use the
|
||||
[schema.sql](schema.sql) provided in this repo to generate the required tables.
|
||||
automatic schema building/migration may come in a future update.
|
||||
|
|
|
@ -2,23 +2,22 @@ package admin
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
type (
|
||||
Session struct {
|
||||
Token string
|
||||
UserID string
|
||||
Account *model.Account
|
||||
Expires time.Time
|
||||
}
|
||||
)
|
||||
|
||||
const TOKEN_LENGTH = 64
|
||||
const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
var ADMIN_BYPASS = func() bool {
|
||||
if global.Args["adminBypass"] == "true" {
|
||||
|
@ -28,24 +27,12 @@ var ADMIN_BYPASS = func() bool {
|
|||
return false
|
||||
}()
|
||||
|
||||
var ADMIN_ID_DISCORD = os.Getenv("DISCORD_ADMIN")
|
||||
|
||||
var sessions []*Session
|
||||
|
||||
func createSession(username string, expires time.Time) Session {
|
||||
func createSession(account *model.Account, expires time.Time) Session {
|
||||
return Session{
|
||||
Token: string(generateToken()),
|
||||
UserID: username,
|
||||
Token: string(controller.GenerateAlnumString(TOKEN_LENGTH)),
|
||||
Account: account,
|
||||
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>
|
||||
|
|
240
admin/http.go
|
@ -9,14 +9,16 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/discord"
|
||||
"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
|
||||
}
|
||||
|
||||
|
@ -25,57 +27,62 @@ func Handler() http.Handler {
|
|||
|
||||
mux.Handle("/login", LoginHandler())
|
||||
mux.Handle("/create-account", createAccountHandler())
|
||||
mux.Handle("/logout", MustAuthorise(LogoutHandler()))
|
||||
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
|
||||
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
|
||||
}
|
||||
|
@ -84,125 +91,109 @@ 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: %s\n", err.Error())
|
||||
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 !discord.CREDENTIALS_PROVIDED || ADMIN_ID_DISCORD == "" {
|
||||
// http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// fmt.Println(discord.CLIENT_ID)
|
||||
// fmt.Println(discord.API_ENDPOINT)
|
||||
// fmt.Println(discord.REDIRECT_URI)
|
||||
if r.Method == http.MethodGet {
|
||||
account, err := controller.GetAccountByRequest(global.DB, r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
if code == "" {
|
||||
pages["login"].Execute(w, loginData{DiscordURI: discord.REDIRECT_URI})
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
auth_token, err := discord.GetOAuthTokenFromCode(code)
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r);
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to retrieve discord access token: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
discord_user, err := discord.GetDiscordUserFromAuth(auth_token)
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
data := LoginRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Password: r.Form.Get("password"),
|
||||
TOTP: r.Form.Get("totp"),
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, data.Username)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to retrieve discord user information: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if discord_user.ID != ADMIN_ID_DISCORD {
|
||||
// TODO: unauthorized user; revoke the token
|
||||
fmt.Printf("Unauthorized login attempted: %s\n", discord_user.ID)
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: check TOTP
|
||||
|
||||
// login success!
|
||||
session := createSession(discord_user.Username, 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: %s\n", err.Error())
|
||||
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)
|
||||
if strings.HasPrefix(global.HTTP_DOMAIN, "https") {
|
||||
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, 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)
|
||||
|
@ -218,33 +209,48 @@ func LogoutHandler() http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
session := GetSession(r)
|
||||
token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
// 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 token_str == "" {
|
||||
cookie, err := r.Cookie(global.COOKIE_TOKEN)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error fetching token cookie: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cookie != nil {
|
||||
token_str = cookie.Value
|
||||
}
|
||||
return new_sessions
|
||||
}(session.Token)
|
||||
|
||||
err := pages["logout"].Execute(w, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin logout page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(token_str) > 0 {
|
||||
err := controller.DeleteToken(global.DB, token_str)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %s\n", err.Error())
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
err := pages["create-account"].Execute(w, nil)
|
||||
err := pages["create-account"].Execute(w, TemplateData{})
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering admin crearte account page: %s\n", err)
|
||||
fmt.Printf("Error rendering create account page: %s\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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,18 @@
|
|||
<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>
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
@ -72,16 +72,14 @@ button:active {
|
|||
<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>
|
||||
|
@ -90,8 +88,8 @@ button:active {
|
|||
<label for="password">Password</label>
|
||||
<input type="password" name="password" value="">
|
||||
|
||||
<label for="code">Code</label>
|
||||
<input type="text" name="code" value="">
|
||||
<label for="totp">TOTP</label>
|
||||
<input type="text" name="totp" value="">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save">Login</button>
|
||||
|
|
175
api/account.go
Normal file
|
@ -0,0 +1,175 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/global"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func handleLogin() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
credentials := LoginRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error())
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: sessions and tokens
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Logged in successfully. TODO: Session tokens\n"))
|
||||
})
|
||||
}
|
||||
|
||||
func handleAccountRegistration() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
credentials := RegisterRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// make sure code exists in DB
|
||||
invite := model.Invite{}
|
||||
err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code)
|
||||
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())
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(invite.ExpiresAt) {
|
||||
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())
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: 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())
|
||||
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()) }
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte("Account created successfully\n"))
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteAccount() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
credentials := LoginRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := controller.GetAccount(global.DB, credentials.Username)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error())
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteAccount(global.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error())
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Account deleted successfully\n"))
|
||||
})
|
||||
}
|
28
api/api.go
|
@ -13,6 +13,12 @@ import (
|
|||
func Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// ACCOUNT ENDPOINTS
|
||||
|
||||
mux.Handle("/v1/login", handleLogin())
|
||||
mux.Handle("/v1/register", handleAccountRegistration())
|
||||
mux.Handle("/v1/delete-account", handleDeleteAccount())
|
||||
|
||||
// ARTIST ENDPOINTS
|
||||
|
||||
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -34,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)
|
||||
}
|
||||
|
@ -49,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)
|
||||
}
|
||||
|
@ -76,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)
|
||||
}
|
||||
|
@ -91,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)
|
||||
}
|
||||
|
@ -115,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)
|
||||
}
|
||||
|
@ -130,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,13 +20,15 @@ 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
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(artists)
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(artists)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -51,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: %s\n", err.Error())
|
||||
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: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -74,7 +80,9 @@ func ServeArtist(artist *model.Artist) http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(artistJSON{
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(artistJSON{
|
||||
Artist: artist,
|
||||
Credits: credits,
|
||||
})
|
||||
|
@ -105,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
|
||||
}
|
||||
|
@ -118,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
|
||||
}
|
||||
|
@ -153,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)
|
||||
}
|
||||
})
|
||||
|
@ -167,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: %s\n", err.Error())
|
||||
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
|
||||
}
|
||||
|
@ -104,7 +116,9 @@ func ServeRelease(release *model.Release) http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err := json.NewEncoder(w).Encode(response)
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err := encoder.Encode(response)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -132,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: %s\n", err.Error())
|
||||
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 }
|
||||
|
@ -155,7 +182,9 @@ func ServeCatalog() http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(catalog)
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(catalog)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -197,14 +226,16 @@ 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
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(w).Encode(release)
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(release)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Release %s created, but failed to send JSON response: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
@ -275,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)
|
||||
}
|
||||
})
|
||||
|
@ -296,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)
|
||||
}
|
||||
})
|
||||
|
@ -337,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)
|
||||
}
|
||||
})
|
||||
|
@ -363,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)
|
||||
}
|
||||
})
|
||||
|
@ -377,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)
|
||||
}
|
||||
})
|
||||
|
|
22
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)
|
||||
}
|
||||
|
||||
|
@ -40,9 +40,11 @@ func ServeAllTracks() http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(tracks)
|
||||
encoder := json.NewEncoder(w)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
@ -52,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)
|
||||
}
|
||||
|
||||
|
@ -62,9 +64,11 @@ func ServeTrack(track *model.Track) http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(Track{ track, releases })
|
||||
encoder := json.NewEncoder(w)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
@ -91,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
|
||||
}
|
||||
|
@ -128,7 +132,9 @@ func UpdateTrack(track *model.Track) http.Handler {
|
|||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(track)
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err = encoder.Encode(track)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
@ -15,6 +16,7 @@ func HandleImageUpload(data *string, directory string, filename string) (string,
|
|||
header := split[0]
|
||||
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
||||
ext, _ := strings.CutPrefix(header, "data:image/")
|
||||
directory = filepath.Join(global.Config.DataDirectory, directory)
|
||||
|
||||
switch ext {
|
||||
case "png":
|
||||
|
|
9
bundle.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
# simple script to pack up arimelody.me for production distribution
|
||||
|
||||
if [ ! -f arimelody-web ]; then
|
||||
echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/
|
124
controller/account.go
Normal file
|
@ -0,0 +1,124 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/model"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func GetAccount(db *sqlx.DB, username string) (*model.Account, error) {
|
||||
var account = model.Account{}
|
||||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
|
||||
var account = model.Account{}
|
||||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
|
||||
if err != nil {
|
||||
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 err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) {
|
||||
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
if tokenStr == "" {
|
||||
cookie, err := r.Cookie(global.COOKIE_TOKEN)
|
||||
if err != nil {
|
||||
// not logged in
|
||||
return nil, nil
|
||||
}
|
||||
tokenStr = cookie.Value
|
||||
}
|
||||
|
||||
token, err := GetToken(db, tokenStr)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "sql: no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New(fmt.Sprintf("GetToken: %s", 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)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateAccount(db *sqlx.DB, account *model.Account) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE account " +
|
||||
"SET username=$2, password=$3, email=$4, avatar_url=$5) " +
|
||||
"WHERE id=$1",
|
||||
account.ID,
|
||||
account.Username,
|
||||
account.Password,
|
||||
account.Email,
|
||||
account.AvatarURL)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
@ -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
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/global"
|
||||
|
@ -15,9 +14,23 @@ import (
|
|||
const API_ENDPOINT = "https://discord.com/api/v10"
|
||||
|
||||
var CREDENTIALS_PROVIDED = true
|
||||
var CLIENT_ID = os.Getenv("DISCORD_CLIENT")
|
||||
var CLIENT_SECRET = os.Getenv("DISCORD_SECRET")
|
||||
var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.HTTP_DOMAIN)
|
||||
var CLIENT_ID = func() string {
|
||||
id := global.Config.Discord.ClientID
|
||||
if id == "" {
|
||||
// fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided.\n")
|
||||
CREDENTIALS_PROVIDED = false
|
||||
}
|
||||
return id
|
||||
}()
|
||||
var CLIENT_SECRET = func() string {
|
||||
secret := global.Config.Discord.Secret
|
||||
if secret == "" {
|
||||
// fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided.\n")
|
||||
CREDENTIALS_PROVIDED = false
|
||||
}
|
||||
return secret
|
||||
}()
|
||||
var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.Config.BaseUrl)
|
||||
var REDIRECT_URI = fmt.Sprintf("https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify", CLIENT_ID, OAUTH_CALLBACK_URI)
|
||||
|
||||
type (
|
||||
|
|
23
docker-compose.example.yml
Normal file
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
web:
|
||||
image: docker.arimelody.me/arimelody.me:latest
|
||||
build: .
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./config.toml:/app/config.toml
|
||||
environment:
|
||||
ARIMELODY_CONFIG: config.toml
|
||||
db:
|
||||
image: postgres:16.1-alpine3.18
|
||||
volumes:
|
||||
- arimelody-db:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: # your database name here!
|
||||
POSTGRES_USER: # your database user here!
|
||||
POSTGRES_PASSWORD: # your database password here!
|
||||
|
||||
volumes:
|
||||
arimelody-db:
|
||||
external: true
|
|
@ -1,22 +0,0 @@
|
|||
services:
|
||||
web:
|
||||
image: docker.arimelody.me/arimelody.me:latest
|
||||
build: .
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
environment:
|
||||
HTTP_DOMAIN: "https://arimelody.me"
|
||||
ARIMELODY_DB_HOST: db
|
||||
DISCORD_ADMIN: # your discord user ID.
|
||||
DISCORD_CLIENT: # your discord OAuth client ID.
|
||||
DISCORD_SECRET: # your discord OAuth secret.
|
||||
db:
|
||||
image: postgres:16.1-alpine3.18
|
||||
volumes:
|
||||
- ./db:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: arimelody
|
||||
POSTGRES_USER: arimelody
|
||||
POSTGRES_PASSWORD: fuckingpassword
|
121
global/config.go
Normal file
|
@ -0,0 +1,121 @@
|
|||
package global
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
type (
|
||||
dbConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Name string `toml:"name"`
|
||||
User string `toml:"user"`
|
||||
Pass string `toml:"pass"`
|
||||
}
|
||||
|
||||
discordConfig struct {
|
||||
AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."`
|
||||
ClientID string `toml:"client_id"`
|
||||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
config struct {
|
||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||
Port int64 `toml:"port"`
|
||||
DataDirectory string `toml:"data_dir"`
|
||||
DB dbConfig `toml:"db"`
|
||||
Discord discordConfig `toml:"discord"`
|
||||
}
|
||||
)
|
||||
|
||||
var Config = func() config {
|
||||
configFile := os.Getenv("ARIMELODY_CONFIG")
|
||||
if configFile == "" {
|
||||
configFile = "config.toml"
|
||||
}
|
||||
|
||||
config := config{
|
||||
BaseUrl: "https://arimelody.me",
|
||||
Port: 8080,
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
configOut, _ := toml.Marshal(&config)
|
||||
os.WriteFile(configFile, configOut, os.ModePerm)
|
||||
fmt.Printf(
|
||||
"A default config.toml has been created. " +
|
||||
"Please configure before running again!\n")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
err = toml.Unmarshal([]byte(data), &config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = handleConfigOverrides(&config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return config
|
||||
}()
|
||||
|
||||
func handleConfigOverrides(config *config) error {
|
||||
var err error
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_PORT"); has {
|
||||
config.Port, err = strconv.ParseInt(env, 10, 0)
|
||||
if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) }
|
||||
}
|
||||
if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env }
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env }
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_ADMIN_ID"); has { config.Discord.AdminID = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
|
||||
|
||||
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
|
@ -0,0 +1,3 @@
|
|||
package global
|
||||
|
||||
const COOKIE_TOKEN string = "AM_TOKEN"
|
|
@ -1,45 +0,0 @@
|
|||
package global
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
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 HTTP_DOMAIN = func() string {
|
||||
domain := os.Getenv("HTTP_DOMAIN")
|
||||
if domain == "" {
|
||||
return "https://arimelody.me"
|
||||
}
|
||||
return domain
|
||||
}()
|
||||
|
||||
var DB *sqlx.DB
|
|
@ -1,18 +1,57 @@
|
|||
package global
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"arimelody-web/colour"
|
||||
"arimelody-web/colour"
|
||||
)
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
"your mother",
|
||||
"awesome powers beyond comprehension",
|
||||
"jared",
|
||||
"the weight of my sins",
|
||||
"the arc reactor",
|
||||
"AA batteries",
|
||||
"15 euro solar panel from ebay",
|
||||
"magnets, how do they work",
|
||||
"a fax machine",
|
||||
"dell optiplex",
|
||||
"a trans girl's nintendo wii",
|
||||
"BASS",
|
||||
"electricity, duh",
|
||||
"seven hamsters in a big wheel",
|
||||
"girls",
|
||||
"mzungu hosting",
|
||||
"golang",
|
||||
"the state of the world right now",
|
||||
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
|
||||
"the good folks at aperture science",
|
||||
"free2play CDs",
|
||||
"aridoodle",
|
||||
"the love of creating",
|
||||
"not for the sake of art; not for the sake of money; we like painting naked people",
|
||||
"30 billion dollars in VC funding",
|
||||
}
|
||||
|
||||
func DefaultHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Server", "arimelody.me")
|
||||
w.Header().Add("Cache-Control", "max-age=2592000")
|
||||
w.Header().Add("Do-Not-Stab", "1")
|
||||
w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
w.Header().Add("X-Hacker", "spare me please")
|
||||
w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
|
||||
w.Header().Add("X-Thinking-With", "Portals")
|
||||
w.Header().Add(
|
||||
"X-Powered-By",
|
||||
PoweredByStrings[rand.Intn(len(PoweredByStrings))],
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
@ -60,4 +99,3 @@ func HTTPLog(next http.Handler) http.Handler {
|
|||
r.Header["User-Agent"][0])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
3
go.mod
|
@ -6,3 +6,6 @@ require (
|
|||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
require golang.org/x/crypto v0.27.0 // indirect
|
||||
require github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
|
|
4
go.sum
|
@ -8,3 +8,7 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
|
|
227
main.go
|
@ -13,23 +13,51 @@ import (
|
|||
"arimelody-web/api"
|
||||
"arimelody-web/global"
|
||||
"arimelody-web/view"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/templates"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
const DEFAULT_PORT int = 8080
|
||||
const DEFAULT_PORT int64 = 8080
|
||||
|
||||
func main() {
|
||||
// initialise database connection
|
||||
var dbHost = os.Getenv("ARIMELODY_DB_HOST")
|
||||
if dbHost == "" { dbHost = "127.0.0.1" }
|
||||
if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env }
|
||||
if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env }
|
||||
if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env }
|
||||
if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env }
|
||||
if global.Config.DB.Host == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.Name == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.User == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if global.Config.DB.Pass == "" {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var err error
|
||||
global.DB, err = sqlx.Connect("postgres", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable")
|
||||
global.DB, err = sqlx.Connect(
|
||||
"postgres",
|
||||
fmt.Sprintf(
|
||||
"host=%s user=%s dbname=%s password='%s' sslmode=disable",
|
||||
global.Config.DB.Host,
|
||||
global.Config.DB.User,
|
||||
global.Config.DB.Name,
|
||||
global.Config.DB.Pass,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
global.DB.SetConnMaxLifetime(time.Minute * 3)
|
||||
|
@ -37,21 +65,198 @@ 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)
|
||||
}
|
||||
|
||||
accountsCount := 0
|
||||
global.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
if accountsCount == 0 {
|
||||
code := controller.GenerateInviteCode(8)
|
||||
|
||||
tx, err := global.DB.Begin()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = tx.Exec("DELETE FROM invite")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)")
|
||||
}
|
||||
|
||||
// start the web server!
|
||||
mux := createServeMux()
|
||||
port := DEFAULT_PORT
|
||||
fmt.Printf("Now serving at http://127.0.0.1:%d\n", port)
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), global.HTTPLog(mux)))
|
||||
fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port)
|
||||
log.Fatal(
|
||||
http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port),
|
||||
global.HTTPLog(global.DefaultHeaders(mux)),
|
||||
))
|
||||
}
|
||||
|
||||
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())) }
|
||||
|
||||
// account token
|
||||
_, err = db.Exec(
|
||||
"CREATE TABLE IF NOT EXISTS token (" +
|
||||
"token TEXT PRIMARY KEY," +
|
||||
"account UUID REFERENCES account(id) ON DELETE CASCADE NOT NULL," +
|
||||
"user_agent TEXT NOT NULL," +
|
||||
"created_at TIMESTAMP NOT NULL DEFAULT current_timestamp)",
|
||||
)
|
||||
if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create token table: %s\n", 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()
|
||||
|
||||
|
||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler()))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler()))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler()))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler("uploads")))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
err := templates.Pages["index"].Execute(w, nil)
|
||||
if err != nil {
|
||||
|
@ -61,7 +266,7 @@ func createServeMux() *http.ServeMux {
|
|||
}
|
||||
staticHandler("public").ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
43
model/account.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
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"`
|
||||
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 (
|
||||
Root AccountPrivilege = "root" // grants all permissions. very dangerous to grant!
|
||||
|
||||
// unused for now
|
||||
CreateInvites AccountPrivilege = "create_invites"
|
||||
ReadAccounts AccountPrivilege = "read_accounts"
|
||||
EditAccounts AccountPrivilege = "edit_accounts"
|
||||
|
||||
ReadReleases AccountPrivilege = "read_releases"
|
||||
EditReleases AccountPrivilege = "edit_releases"
|
||||
|
||||
ReadTracks AccountPrivilege = "read_tracks"
|
||||
EditTracks AccountPrivilege = "edit_tracks"
|
||||
|
||||
ReadArtists AccountPrivilege = "read_artists"
|
||||
EditArtists AccountPrivilege = "edit_artists"
|
||||
)
|
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"`
|
||||
}
|
BIN
public/img/buttons/aikoyori.gif
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
public/img/buttons/ioletsgo.gif
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
public/img/buttons/ipg.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
public/img/buttons/isabelroses.gif
Normal file
After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.8 KiB |
BIN
public/img/buttons/notnite.png
Normal file
After Width: | Height: | Size: 292 B |
BIN
public/img/buttons/retr0id_now.gif
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
public/img/buttons/stardust.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/img/buttons/xenia.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
|
@ -107,7 +107,7 @@ ul.links li {
|
|||
}
|
||||
|
||||
ul.links li a {
|
||||
padding: .2em .5em;
|
||||
padding: .4em .5em;
|
||||
border: 1px solid var(--links);
|
||||
color: var(--links);
|
||||
border-radius: 2px;
|
||||
|
|
94
schema.sql
|
@ -1,18 +1,74 @@
|
|||
CREATE SCHEMA arimelody AUTHORIZATION arimelody;
|
||||
|
||||
--
|
||||
-- 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);
|
||||
|
||||
|
||||
--
|
||||
-- Artists (should be applicable to all art)
|
||||
--
|
||||
CREATE TABLE public.artist (
|
||||
CREATE TABLE arimelody.artist (
|
||||
id character varying(64),
|
||||
name text NOT NULL,
|
||||
website text,
|
||||
avatar text
|
||||
);
|
||||
ALTER TABLE public.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
|
||||
ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
|
||||
|
||||
--
|
||||
-- Music releases
|
||||
--
|
||||
CREATE TABLE public.musicrelease (
|
||||
CREATE TABLE arimelody.musicrelease (
|
||||
id character varying(64) NOT NULL,
|
||||
visible bool DEFAULT false,
|
||||
title text NOT NULL,
|
||||
|
@ -25,56 +81,60 @@ CREATE TABLE public.musicrelease (
|
|||
copyright text,
|
||||
copyrightURL text
|
||||
);
|
||||
ALTER TABLE public.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
|
||||
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
|
||||
|
||||
--
|
||||
-- Music links (external platform links under a release)
|
||||
--
|
||||
CREATE TABLE public.musiclink (
|
||||
CREATE TABLE arimelody.musiclink (
|
||||
release character varying(64) NOT NULL,
|
||||
name text NOT NULL,
|
||||
url text NOT NULL
|
||||
);
|
||||
ALTER TABLE public.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
|
||||
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
|
||||
|
||||
--
|
||||
-- Music credits (artist credits under a release)
|
||||
--
|
||||
CREATE TABLE public.musiccredit (
|
||||
CREATE TABLE arimelody.musiccredit (
|
||||
release character varying(64) NOT NULL,
|
||||
artist character varying(64) NOT NULL,
|
||||
role text NOT NULL,
|
||||
is_primary boolean DEFAULT false
|
||||
);
|
||||
ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
|
||||
|
||||
--
|
||||
-- Music tracks (tracks under a release)
|
||||
--
|
||||
CREATE TABLE public.musictrack (
|
||||
CREATE TABLE arimelody.musictrack (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
title text NOT NULL,
|
||||
description text,
|
||||
lyrics text,
|
||||
preview_url text
|
||||
);
|
||||
ALTER TABLE public.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
|
||||
ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
|
||||
|
||||
--
|
||||
-- Music release/track pairs
|
||||
--
|
||||
CREATE TABLE public.musicreleasetrack (
|
||||
CREATE TABLE arimelody.musicreleasetrack (
|
||||
release character varying(64) NOT NULL,
|
||||
track uuid NOT NULL,
|
||||
number integer NOT NULL
|
||||
);
|
||||
ALTER TABLE public.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
|
||||
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track);
|
||||
|
||||
--
|
||||
-- Foreign keys
|
||||
--
|
||||
ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES public.artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON DELETE CASCADE;
|
||||
ALTER TABLE public.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ALTER TABLE public.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON DELETE CASCADE;
|
||||
ALTER TABLE public.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES public.musictrack(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.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;
|
||||
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(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: %s\n", err.Error())
|
||||
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
|
||||
|
|
|
@ -129,12 +129,17 @@
|
|||
OpenTerminal
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://silver.bliss.town/" target="_blank">
|
||||
Silver.js
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 class="typeout">
|
||||
## cool people
|
||||
## cool critters
|
||||
</h2>
|
||||
|
||||
<div id="web-buttons">
|
||||
|
@ -153,8 +158,29 @@
|
|||
<a href="https://elke.cafe" target="_blank">
|
||||
<img src="/img/buttons/elke.gif" alt="elke web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://itzzen.net" target="_blank">
|
||||
<img src="/img/buttons/itzzen.png" alt="itzzen web button" width="88" height="31">
|
||||
<a href="https://invoxiplaygames.uk/" target="_blank">
|
||||
<img src="/img/buttons/ipg.png" alt="InvoxiPlayGames web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://ioletsgo.gay" target="_blank">
|
||||
<img src="/img/buttons/ioletsgo.gif" alt="ioletsgo web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://notnite.com/" target="_blank">
|
||||
<img src="/img/buttons/notnite.png" alt="notnite web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://www.da.vidbuchanan.co.uk/" target="_blank">
|
||||
<img src="/img/buttons/retr0id_now.gif" alt="retr0id web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://aikoyori.xyz" target="_blank">
|
||||
<img src="/img/buttons/aikoyori.gif" alt="aikoyori web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://xenia.blahaj.land/" target="_blank">
|
||||
<img src="/img/buttons/xenia.png" alt="xenia web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://stardust.elysium.gay/" target="_blank">
|
||||
<img src="/img/buttons/stardust.png" alt="stardust web button" width="88" height="31">
|
||||
</a>
|
||||
<a href="https://isabelroses.com/" target="_blank">
|
||||
<img src="/img/buttons/isabelroses.gif" alt="isabel roses web button" width="88" height="31">
|
||||
</a>
|
||||
|
||||
<hr>
|
||||
|
|