diff --git a/.dockerignore b/.dockerignore index 3ba6b11..e2d5600 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,6 @@ uploads/ test/ tmp/ -db/ res/ docker-compose.yml docker-compose-test.yml diff --git a/.gitignore b/.gitignore index cccde2b..025d915 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ uploads/ docker-compose*.yml !docker-compose.example.yml config*.toml +>>>>>>> dev diff --git a/Dockerfile b/Dockerfile index 278f01a..0e0d2a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web # --- -FROM build-stage AS build-release-stage +FROM scratch WORKDIR /app diff --git a/README.md b/README.md index df0c351..0873ff6 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ library for others to use in their own sites. exciting stuff! ## running the server should be run once to generate a default `config.toml` file. -configure as needed. note that a valid DB connection is required, and the admin -panel will be disabled without valid discord app credentials (this can however -be bypassed by running the server with `-adminBypass`). +configure as needed. a valid DB connection is required to run this website. +if no admin users exist, an invite code will be provided. invite codes are +the only way to create admin accounts at this time. the configuration may be overridden using environment variables in the format `ARIMELODY__`. for example, `db.host` in the config may @@ -32,6 +32,16 @@ be overridden with `ARIMELODY_DB_HOST`. the location of the configuration file can also be overridden with `ARIMELODY_CONFIG`. +## command arguments + +by default, `arimelody-web` will spin up a web server as usual. instead, +arguments may be supplied to run administrative actions. the web server doesn't +need to be up for this, making this ideal for some offline maintenance. + +- `createInvite`: Creates an invite code to register new accounts. +- `purgeInvites`: Deletes all available invite codes. +- `deleteAccount `: Deletes an account with a given `username`. + ## database the server requires a postgres database to run. you can use the diff --git a/admin/admin.go b/admin/admin.go deleted file mode 100644 index a12e85b..0000000 --- a/admin/admin.go +++ /dev/null @@ -1,48 +0,0 @@ -package admin - -import ( - "fmt" - "math/rand" - "time" - - "arimelody-web/global" -) - -type ( - Session struct { - Token string - UserID string - Expires time.Time - } -) - -const TOKEN_LENGTH = 64 -const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -var ADMIN_BYPASS = func() bool { - if global.Args["adminBypass"] == "true" { - fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)") - return true - } - return false -}() - -var sessions []*Session - -func createSession(userID string, expires time.Time) Session { - return Session{ - Token: string(generateToken()), - UserID: userID, - Expires: expires, - } -} - -func generateToken() string { - var token []byte - - for i := 0; i < TOKEN_LENGTH; i++ { - token = append(token, TOKEN_CHARS[rand.Intn(len(TOKEN_CHARS))]) - } - - return string(token) -} diff --git a/admin/artisthttp.go b/admin/artisthttp.go index ba26240..af42cb1 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -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) diff --git a/admin/components/credits/editcredits.html b/admin/components/credits/editcredits.html index 999740a..94dc268 100644 --- a/admin/components/credits/editcredits.html +++ b/admin/components/credits/editcredits.html @@ -52,7 +52,6 @@ makeMagicList(creditList, ".credit"); function rigCredit(el) { - console.log(el); const artistID = el.dataset.artist; const deleteBtn = el.querySelector("a.delete"); diff --git a/admin/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index dba57a6..0500532 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -12,12 +12,12 @@
    - {{range .Tracks}} -
  • + {{range $i, $track := .Tracks}} +
  • - {{.Number}} - {{.Title}} + {{$track.Add $i 1}} + {{$track.Title}}

    Delete
    diff --git a/admin/http.go b/admin/http.go index b69342f..4dbc66b 100644 --- a/admin/http.go +++ b/admin/http.go @@ -8,17 +8,17 @@ import ( "path/filepath" "strings" "time" - "encoding/json" - "arimelody-web/global" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/model" + "github.com/jmoiron/sqlx" "golang.org/x/crypto/bcrypt" ) -type loginData struct { - DiscordURI string +type TemplateData struct { + Account *model.Account Token string } @@ -26,57 +26,64 @@ func Handler() http.Handler { mux := http.NewServeMux() mux.Handle("/login", LoginHandler()) - mux.Handle("/logout", MustAuthorise(LogoutHandler())) + mux.Handle("/register", createAccountHandler()) + mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) + // TODO: /admin/account mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) - mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist()))) - mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack()))) + mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease()))) + mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) + mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack()))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - session := GetSession(r) - if session == nil { + account, err := controller.GetAccountByRequest(global.DB, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) + } + if account == nil { http.Redirect(w, r, "/admin/login", http.StatusFound) return } releases, err := controller.GetAllReleases(global.DB, false, 0, true) if err != nil { - fmt.Printf("FATAL: Failed to pull releases: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } artists, err := controller.GetAllArtists(global.DB) if err != nil { - fmt.Printf("FATAL: Failed to pull artists: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } tracks, err := controller.GetOrphanTracks(global.DB) if err != nil { - fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type IndexData struct { + Account *model.Account Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } err = pages["index"].Execute(w, IndexData{ + Account: account, Releases: releases, Artists: artists, Tracks: tracks, }) if err != nil { - fmt.Printf("Error executing template: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -85,76 +92,43 @@ func Handler() http.Handler { return mux } -func MustAuthorise(next http.Handler) http.Handler { +func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := GetSession(r) - if session == nil { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + account, err := controller.GetAccountByRequest(db, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + return + } + if account == nil { + // TODO: include context in redirect + http.Redirect(w, r, "/admin/login", http.StatusFound) return } - ctx := context.WithValue(r.Context(), "session", session) + ctx := context.WithValue(r.Context(), "account", account) + next.ServeHTTP(w, r.WithContext(ctx)) }) } -func GetSession(r *http.Request) *Session { - if ADMIN_BYPASS { - return &Session{} - } - - var token = "" - // is the session token in context? - var ctx_session = r.Context().Value("session") - if ctx_session != nil { - token = ctx_session.(*Session).Token - } - - // okay, is it in the auth header? - if token == "" { - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - token = r.Header.Get("Authorization")[7:] - } - } - // finally, is it in the cookie? - if token == "" { - cookie, err := r.Cookie("token") - if err != nil { - return nil - } - token = cookie.Value - } - - var session *Session = nil - for _, s := range sessions { - if s.Expires.Before(time.Now()) { - // expired session. remove it from the list! - new_sessions := []*Session{} - for _, ns := range sessions { - if ns.Token == s.Token { - continue - } - new_sessions = append(new_sessions, ns) - } - sessions = new_sessions - continue - } - - if s.Token == token { - session = s - break - } - } - - return session -} - func LoginHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - err := pages["login"].Execute(w, nil) + account, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Printf("Error rendering admin login page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + return + } + if account != nil { + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + err = pages["login"].Execute(w, TemplateData{}) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -166,41 +140,53 @@ func LoginHandler() http.Handler { return } - type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - TOTP string `json:"totp"` - } - - data := LoginRequest{} - err := json.NewDecoder(r.Body).Decode(&data) + err := r.ParseForm() if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - account, err := controller.GetAccount(global.DB, data.Username) + type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + TOTP string `json:"totp"` + } + credentials := LoginRequest{ + Username: r.Form.Get("username"), + Password: r.Form.Get("password"), + TOTP: r.Form.Get("totp"), + } + + account, err := controller.GetAccount(global.DB, credentials.Username) if err != nil { - http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + if account == nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) return } - err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) if err != nil { - http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) + http.Error(w, "Invalid username or password", http.StatusBadRequest) return } // TODO: check TOTP // login success! - session := createSession(account.ID, time.Now().Add(24 * time.Hour)) - sessions = append(sessions, &session) + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } cookie := http.Cookie{} - cookie.Name = "token" - cookie.Value = session.Token - cookie.Expires = time.Now().Add(24 * time.Hour) + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt if strings.HasPrefix(global.Config.BaseUrl, "https") { cookie.Secure = true } @@ -208,7 +194,10 @@ func LoginHandler() http.Handler { cookie.Path = "/" http.SetCookie(w, &cookie) - err = pages["login"].Execute(w, loginData{Token: session.Token}) + err = pages["login"].Execute(w, TemplateData{ + Account: account, + Token: token.Token, + }) if err != nil { fmt.Printf("Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -224,22 +213,168 @@ func LogoutHandler() http.Handler { return } - session := GetSession(r) + tokenStr := controller.GetTokenFromRequest(global.DB, r) - // remove this session from the list - sessions = func (token string) []*Session { - new_sessions := []*Session{} - for _, session := range sessions { - if session.Token != token { - new_sessions = append(new_sessions, session) - } + if len(tokenStr) > 0 { + err := controller.DeleteToken(global.DB, tokenStr) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } - return new_sessions - }(session.Token) + } - err := pages["logout"].Execute(w, nil) + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = "" + cookie.Expires = time.Now() + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/admin/login", http.StatusFound) + }) +} + +func createAccountHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checkAccount, err := controller.GetAccountByRequest(global.DB, r) if err != nil { - fmt.Printf("Error rendering admin logout page: %s\n", err) + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if checkAccount != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + type CreateAccountResponse struct { + Account *model.Account + Message string + } + + render := func(data CreateAccountResponse) { + err := pages["create-account"].Execute(w, data) + if err != nil { + fmt.Printf("WARN: Error rendering create account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + + if r.Method == http.MethodGet { + render(CreateAccountResponse{}) + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err = r.ParseForm() + if err != nil { + render(CreateAccountResponse{ + Message: "Malformed data.", + }) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` + } + credentials := RegisterRequest{ + Username: r.Form.Get("username"), + Email: r.Form.Get("email"), + Password: r.Form.Get("password"), + Invite: r.Form.Get("invite"), + } + + // make sure code exists in DB + invite, err := controller.GetInvite(global.DB, credentials.Invite) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + if invite == nil || time.Now().After(invite.ExpiresAt) { + if invite != nil { + err := controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + } + render(CreateAccountResponse{ + Message: "Invalid invite code.", + }) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: credentials.Email, + AvatarURL: "/img/default-avatar.png", + } + err = controller.CreateAccount(global.DB, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + render(CreateAccountResponse{ + Message: "An account with that username already exists.", + }) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + err = controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + + // registration success! + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + // gracefully redirect user to login page + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + + err = pages["login"].Execute(w, TemplateData{ + Account: &account, + Token: token.Token, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/admin/releasehttp.go b/admin/releasehttp.go index 54d0fd6..9132fe8 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -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) diff --git a/admin/static/admin.css b/admin/static/admin.css index 0d1269c..510ee8b 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -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; diff --git a/admin/templates.go b/admin/templates.go index b7aaf9e..e91313a 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -18,6 +18,11 @@ var pages = map[string]*template.Template{ filepath.Join("views", "prideflag.html"), filepath.Join("admin", "views", "login.html"), )), + "create-account": template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "create-account.html"), + )), "logout": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), filepath.Join("views", "prideflag.html"), diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 148b7d8..2cea123 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -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) diff --git a/admin/views/create-account.html b/admin/views/create-account.html new file mode 100644 index 0000000..5d92627 --- /dev/null +++ b/admin/views/create-account.html @@ -0,0 +1,102 @@ +{{define "head"}} +Register - ari melody 💫 + + + +{{end}} + +{{define "content"}} +
    + {{if .Message}} +

    {{.Message}}

    + {{end}} + + +
    + + + + + + + + + + + +
    + + + +
    +{{end}} diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index b54361a..8cd88f0 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -1,5 +1,6 @@ {{define "head"}} -Editing {{.Name}} - ari melody 💫 +Editing {{.Artist.Name}} - ari melody 💫 + {{end}} @@ -8,20 +9,20 @@

    Editing Artist

    -
    +
    - +

    Name

    - +

    Website

    - +
    @@ -36,13 +37,13 @@ {{if .Credits}} {{range .Credits}}
    - +
    -

    {{.Release.Title}}

    -

    {{.Release.PrintArtists true true}}

    +

    {{.Artist.Release.Title}}

    +

    {{.Artist.Release.PrintArtists true true}}

    - Role: {{.Role}} - {{if .Primary}} + Role: {{.Artist.Role}} + {{if .Artist.Primary}} (Primary) {{end}}

    diff --git a/admin/views/edit-release.html b/admin/views/edit-release.html index d106bb5..9c1ba99 100644 --- a/admin/views/edit-release.html +++ b/admin/views/edit-release.html @@ -1,6 +1,6 @@ {{define "head"}} -Editing {{.Title}} - ari melody 💫 - +Editing {{.Release.Title}} - ari melody 💫 + {{end}} @@ -8,21 +8,21 @@ {{define "content"}}
    -
    +
    - +

    - +

    Type - {{$t := .ReleaseType}} + {{$t := .Release.ReleaseType}} + >{{.Release.Description}}
    Release Date - +
    Buy Name - +
    Buy Link - +
    Copyright - +
    Copyright URL - +
    Visible
    -

    Credits ({{len .Credits}})

    +

    Credits ({{len .Release.Credits}})

    Edit
    - {{range .Credits}} + {{range .Release.Credits}}
    @@ -122,37 +122,37 @@
    {{end}} - {{if not .Credits}} + {{if not .Release.Credits}}

    There are no credits.

    {{end}}
    -

    Links ({{len .Links}})

    +

    Links ({{len .Release.Links}})

    Edit
    -

    Tracklist ({{len .Tracks}})

    +

    Tracklist ({{len .Release.Tracks}})

    Edit
    - {{range $i, $track := .Tracks}} + {{range $i, $track := .Release.Tracks}}

    {{.Add $i 1}} diff --git a/admin/views/edit-track.html b/admin/views/edit-track.html index 2b0fed5..5bb74eb 100644 --- a/admin/views/edit-track.html +++ b/admin/views/edit-track.html @@ -8,30 +8,30 @@

    Editing Track

    -
    +

    Title

    - +

    Description

    + >{{.Track.Description}}

    Lyrics

    + >{{.Track.Lyrics}}
    diff --git a/admin/views/layout.html b/admin/views/layout.html index ca9ce1f..0a33b72 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -17,9 +17,22 @@
    diff --git a/admin/views/login.html b/admin/views/login.html index f615746..16c0fcc 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -10,6 +10,65 @@ p a { a.discord { color: #5865F2; } + +form { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +form div { + width: 20rem; +} + +form button { + margin-top: 1rem; +} + +label { + width: 100%; + margin: 1rem 0 .5rem 0; + display: block; + color: #10101080; +} +input { + width: 100%; + margin: .5rem 0; + padding: .3rem .5rem; + display: block; + border-radius: 4px; + border: 1px solid #808080; + font-size: inherit; + font-family: inherit; + color: inherit; +} +input[disabled] { + opacity: .5; + cursor: not-allowed; +} + +button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} {{end}} @@ -17,15 +76,28 @@ a.discord {
    {{if .Token}} - +

    Logged in successfully. - You should be redirected to /admin in 5 seconds. + You should be redirected to /admin soon.

    {{else}} -

    Log in with Discord.

    +
    +
    + + + + + + + + +
    + + +
    {{end}}
    diff --git a/api/account.go b/api/account.go index 37c4c7f..3ce52c8 100644 --- a/api/account.go +++ b/api/account.go @@ -35,25 +35,35 @@ func handleLogin() http.HandlerFunc { account, err := controller.GetAccount(global.DB, credentials.Username) if err != nil { - if strings.Contains(err.Error(), "no rows") { - http.Error(w, "Invalid username or password", http.StatusBadRequest) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + if account == nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } - err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) if err != nil { http.Error(w, "Invalid username or password", http.StatusBadRequest) return } - // TODO: sessions and tokens + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + type LoginResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } - w.WriteHeader(http.StatusOK) - w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) + err = json.NewEncoder(w).Encode(LoginResponse{ + Token: token.Token, + ExpiresAt: token.ExpiresAt, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } }) } @@ -68,7 +78,7 @@ func handleAccountRegistration() http.HandlerFunc { Username string `json:"username"` Email string `json:"email"` Password string `json:"password"` - Code string `json:"code"` + Invite string `json:"invite"` } credentials := RegisterRequest{} @@ -79,50 +89,65 @@ func handleAccountRegistration() http.HandlerFunc { } // make sure code exists in DB - invite := model.Invite{} - err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code) + invite, err := controller.GetInvite(global.DB, credentials.Invite) if err != nil { - if strings.Contains(err.Error(), "no rows") { - http.Error(w, "Invalid invite code", http.StatusBadRequest) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + if invite == nil { + http.Error(w, "Invalid invite code", http.StatusBadRequest) + return + } if time.Now().After(invite.ExpiresAt) { + err := controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } http.Error(w, "Invalid invite code", http.StatusBadRequest) - _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } account := model.Account{ Username: credentials.Username, - Password: hashedPassword, + Password: string(hashedPassword), Email: credentials.Email, AvatarURL: "/img/default-avatar.png", } err = controller.CreateAccount(global.DB, &account) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error()) + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + http.Error(w, "An account with that username already exists", http.StatusBadRequest) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } + err = controller.DeleteInvite(global.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - w.WriteHeader(http.StatusCreated) - w.Write([]byte("Account created successfully\n")) + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) + type LoginResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + + err = json.NewEncoder(w).Encode(LoginResponse{ + Token: token.Token, + ExpiresAt: token.ExpiresAt, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } }) } @@ -151,20 +176,22 @@ func handleDeleteAccount() http.HandlerFunc { http.Error(w, "Invalid username or password", http.StatusBadRequest) return } - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) if err != nil { - http.Error(w, "Invalid username or password", http.StatusBadRequest) + http.Error(w, "Invalid password", http.StatusBadRequest) return } - err = controller.DeleteAccount(global.DB, account.ID) + // TODO: check TOTP + + err = controller.DeleteAccount(global.DB, account.Username) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/api/api.go b/api/api.go index f7c1402..af6c6a7 100644 --- a/api/api.go +++ b/api/api.go @@ -40,10 +40,10 @@ func Handler() http.Handler { ServeArtist(artist).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/artist/{id} (admin) - admin.MustAuthorise(UpdateArtist(artist)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, UpdateArtist(artist)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/artist/{id} (admin) - admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, DeleteArtist(artist)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -55,7 +55,7 @@ func Handler() http.Handler { ServeAllArtists().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/artist (admin) - admin.MustAuthorise(CreateArtist()).ServeHTTP(w, r) + admin.RequireAccount(global.DB, CreateArtist()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -82,10 +82,10 @@ func Handler() http.Handler { ServeRelease(release).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/music/{id} (admin) - admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/music/{id} (admin) - admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, DeleteRelease(release)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -97,7 +97,7 @@ func Handler() http.Handler { ServeCatalog().ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/music (admin) - admin.MustAuthorise(CreateRelease()).ServeHTTP(w, r) + admin.RequireAccount(global.DB, CreateRelease()).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -121,13 +121,13 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track/{id} (admin) - admin.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r) case http.MethodPut: // PUT /api/v1/track/{id} (admin) - admin.MustAuthorise(UpdateTrack(track)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, UpdateTrack(track)).ServeHTTP(w, r) case http.MethodDelete: // DELETE /api/v1/track/{id} (admin) - admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r) + admin.RequireAccount(global.DB, DeleteTrack(track)).ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -136,10 +136,10 @@ func Handler() http.Handler { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) - admin.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r) + admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r) case http.MethodPost: // POST /api/v1/track (admin) - admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r) + admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r) default: http.NotFound(w, r) } diff --git a/api/artist.go b/api/artist.go index c793e23..c46db59 100644 --- a/api/artist.go +++ b/api/artist.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "arimelody-web/admin" "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" @@ -21,7 +20,7 @@ func ServeAllArtists() http.Handler { var artists = []*model.Artist{} artists, err := controller.GetAllArtists(global.DB) if err != nil { - fmt.Printf("FATAL: Failed to serve all artists: %s\n", err) + fmt.Printf("WARN: Failed to serve all artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -53,12 +52,17 @@ func ServeArtist(artist *model.Artist) http.Handler { } ) - show_hidden_releases := admin.GetSession(r) != nil + account, err := controller.GetAccountByRequest(global.DB, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + show_hidden_releases := account != nil - var dbCredits []*model.Credit dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) if err != nil { - fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -109,7 +113,7 @@ func CreateArtist() http.Handler { http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) return } - fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -122,7 +126,7 @@ func UpdateArtist(artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := json.NewDecoder(r.Body).Decode(&artist) if err != nil { - fmt.Printf("FATAL: Failed to update artist: %s\n", err) + fmt.Printf("WARN: Failed to update artist: %s\n", err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } @@ -157,7 +161,7 @@ func UpdateArtist(artist *model.Artist) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -171,7 +175,7 @@ func DeleteArtist(artist *model.Artist) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err) + fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/api/release.go b/api/release.go index 2288153..d17fb5f 100644 --- a/api/release.go +++ b/api/release.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "arimelody-web/admin" "arimelody-web/global" "arimelody-web/controller" "arimelody-web/model" @@ -19,10 +18,23 @@ import ( func ServeRelease(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases - authorised := admin.GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return + privileged := false + if !release.Visible { + account, err := controller.GetAccountByRequest(global.DB, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if account != nil { + // TODO: check privilege on release + privileged = true + } + + if !privileged { + http.NotFound(w, r) + return + } } type ( @@ -53,18 +65,18 @@ func ServeRelease(release *model.Release) http.Handler { Links: make(map[string]string), } - if authorised || release.IsReleased() { + if release.IsReleased() || privileged { // get credits credits, err := controller.GetReleaseCredits(global.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } for _, credit := range credits { artist, err := controller.GetArtist(global.DB, credit.Artist.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -79,7 +91,7 @@ func ServeRelease(release *model.Release) http.Handler { // get tracks tracks, err := controller.GetReleaseTracks(global.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -94,7 +106,7 @@ func ServeRelease(release *model.Release) http.Handler { // get links links, err := controller.GetReleaseLinks(global.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -134,11 +146,24 @@ func ServeCatalog() http.Handler { } catalog := []Release{} - authorised := admin.GetSession(r) != nil + account, err := controller.GetAccountByRequest(global.DB, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } for _, release := range releases { - if !release.Visible && !authorised { - continue + if !release.Visible { + privileged := false + if account != nil { + // TODO: check privilege on release + privileged = true + } + if !privileged { + continue + } } + artists := []string{} for _, credit := range release.Credits { if !credit.Primary { continue } @@ -201,7 +226,7 @@ func CreateRelease() http.Handler { http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) return } - fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -281,7 +306,7 @@ func UpdateRelease(release *model.Release) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -302,7 +327,7 @@ func UpdateReleaseTracks(release *model.Release) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -343,7 +368,7 @@ func UpdateReleaseCredits(release *model.Release) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -369,7 +394,7 @@ func UpdateReleaseLinks(release *model.Release) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -383,7 +408,7 @@ func DeleteRelease(release *model.Release) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/api/track.go b/api/track.go index f6d5578..ebbaa10 100644 --- a/api/track.go +++ b/api/track.go @@ -28,7 +28,7 @@ func ServeAllTracks() http.Handler { var dbTracks = []*model.Track{} dbTracks, err := controller.GetAllTracks(global.DB) if err != nil { - fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err) + fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -44,7 +44,7 @@ func ServeAllTracks() http.Handler { encoder.SetIndent("", "\t") err = encoder.Encode(tracks) if err != nil { - fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err) + fmt.Printf("WARN: Failed to serve all tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -54,7 +54,7 @@ func ServeTrack(track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) if err != nil { - fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err) + fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } @@ -68,7 +68,7 @@ func ServeTrack(track *model.Track) http.Handler { encoder.SetIndent("", "\t") err = encoder.Encode(Track{ track, releases }) if err != nil { - fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err) + fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -95,7 +95,7 @@ func CreateTrack() http.Handler { id, err := controller.CreateTrack(global.DB, &track) if err != nil { - fmt.Printf("FATAL: Failed to create track: %s\n", err) + fmt.Printf("WARN: Failed to create track: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/controller/account.go b/controller/account.go index a34cd27..362e297 100644 --- a/controller/account.go +++ b/controller/account.go @@ -1,8 +1,12 @@ package controller import ( + "arimelody-web/global" "arimelody-web/model" - "math/rand" + "errors" + "fmt" + "net/http" + "strings" "github.com/jmoiron/sqlx" ) @@ -12,6 +16,9 @@ func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } return nil, err } @@ -23,49 +30,98 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) { err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email) if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } return nil, err } return &account, nil } +func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) { + if token == "" { return nil, nil } + + account := model.Account{} + + err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string { + tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if len(tokenStr) > 0 { + return tokenStr + } + + cookie, err := r.Cookie(global.COOKIE_TOKEN) + if err != nil { + return "" + } + return cookie.Value +} + +func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { + tokenStr := GetTokenFromRequest(db, r) + + token, err := GetToken(db, tokenStr) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, errors.New("GetToken: " + err.Error()) + } + + // does user-agent match the token? + if r.UserAgent() != token.UserAgent { + // invalidate the token + DeleteToken(db, tokenStr) + fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent()) + // TODO: log unauthorised activity to the user + return nil, errors.New("User agent mismatch") + } + + return GetAccountByToken(db, tokenStr) +} + func CreateAccount(db *sqlx.DB, account *model.Account) error { - _, err := db.Exec( - "INSERT INTO account (username, password, email, avatar_url) " + - "VALUES ($1, $2, $3, $4)", - account.Username, - account.Password, - account.Email, - account.AvatarURL) + err := db.Get( + &account.ID, + "INSERT INTO account (username, password, email, avatar_url) " + + "VALUES ($1, $2, $3, $4) " + + "RETURNING id", + account.Username, + account.Password, + account.Email, + account.AvatarURL, + ) return err } func UpdateAccount(db *sqlx.DB, account *model.Account) error { _, err := db.Exec( - "UPDATE account " + - "SET username=$2, password=$3, email=$4, avatar_url=$5) " + - "WHERE id=$1", - account.ID, - account.Username, - account.Password, - account.Email, - account.AvatarURL) + "UPDATE account " + + "SET username=$2, password=$3, email=$4, avatar_url=$5) " + + "WHERE id=$1", + account.ID, + account.Username, + account.Password, + account.Email, + account.AvatarURL, + ) return err } -func DeleteAccount(db *sqlx.DB, accountID string) error { - _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) +func DeleteAccount(db *sqlx.DB, username string) error { + _, err := db.Exec("DELETE FROM account WHERE username=$1", username) return err } - -var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -func GenerateInviteCode(length int) []byte { - code := []byte{} - for i := 0; i < length; i++ { - code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) - } - return code -} diff --git a/controller/controller.go b/controller/controller.go new file mode 100644 index 0000000..44194e4 --- /dev/null +++ b/controller/controller.go @@ -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 +} + diff --git a/controller/invite.go b/controller/invite.go new file mode 100644 index 0000000..f30db64 --- /dev/null +++ b/controller/invite.go @@ -0,0 +1,67 @@ +package controller + +import ( + "arimelody-web/model" + "math/rand" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) { + invite := model.Invite{} + + err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &invite, nil +} + +func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invite, error) { + invite := model.Invite{ + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(lifetime), + } + + code := []byte{} + for i := 0; i < length; i++ { + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) + } + invite.Code = string(code) + + _, err := db.Exec( + "INSERT INTO invite (code, created_at, expires_at) " + + "VALUES ($1, $2, $3)", + invite.Code, + invite.CreatedAt, + invite.ExpiresAt, + ) + if err != nil { + return nil, err + } + + return &invite, nil +} + +func DeleteInvite(db *sqlx.DB, code string) error { + _, err := db.Exec("DELETE FROM invite WHERE code=$1", code) + return err +} + +func DeleteAllInvites(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM invite") + return err +} + +func DeleteExpiredInvites(db *sqlx.DB) error { + _, err := db.Exec("DELETE FROM invite WHERE expires_atcurrent_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 +} + diff --git a/global/config.go b/global/config.go index d8c9f3d..0115c3f 100644 --- a/global/config.go +++ b/global/config.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "strconv" - "strings" "github.com/jmoiron/sqlx" "github.com/pelletier/go-toml/v2" @@ -57,13 +56,13 @@ var Config = func() config { err = toml.Unmarshal([]byte(data), &config) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err) os.Exit(1) } err = handleConfigOverrides(&config) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error()) + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err) os.Exit(1) } @@ -92,30 +91,4 @@ func handleConfigOverrides(config *config) error { return nil } -var Args = func() map[string]string { - args := map[string]string{} - - index := 0 - for index < len(os.Args[1:]) { - arg := os.Args[index + 1] - if !strings.HasPrefix(arg, "-") { - fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg) - os.Exit(1) - } - - if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") { - args[arg[1:]] = "true" - index += 1 - continue - } - - val := os.Args[index + 2] - args[arg[1:]] = val - // fmt.Printf("%s: %s\n", arg[1:], val) - index += 2 - } - - return args -}() - var DB *sqlx.DB diff --git a/global/const.go b/global/const.go new file mode 100644 index 0000000..157668d --- /dev/null +++ b/global/const.go @@ -0,0 +1,3 @@ +package global + +const COOKIE_TOKEN string = "AM_TOKEN" diff --git a/global/funcs.go b/global/funcs.go index c0462db..49edb01 100644 --- a/global/funcs.go +++ b/global/funcs.go @@ -58,12 +58,12 @@ func DefaultHeaders(next http.Handler) http.Handler { type LoggingResponseWriter struct { http.ResponseWriter - Code int + Status int } -func (lrw *LoggingResponseWriter) WriteHeader(code int) { - lrw.Code = code - lrw.ResponseWriter.WriteHeader(code) +func (lrw *LoggingResponseWriter) WriteHeader(status int) { + lrw.Status = status + lrw.ResponseWriter.WriteHeader(status) } func HTTPLog(next http.Handler) http.Handler { @@ -81,22 +81,21 @@ func HTTPLog(next http.Handler) http.Handler { elapsed = strconv.Itoa(difference) } - codeColour := colour.Reset + statusColour := colour.Reset - if lrw.Code - 600 <= 0 { codeColour = colour.Red } - if lrw.Code - 500 <= 0 { codeColour = colour.Yellow } - if lrw.Code - 400 <= 0 { codeColour = colour.White } - if lrw.Code - 300 <= 0 { codeColour = colour.Green } + if lrw.Status - 600 <= 0 { statusColour = colour.Red } + if lrw.Status - 500 <= 0 { statusColour = colour.Yellow } + if lrw.Status - 400 <= 0 { statusColour = colour.White } + if lrw.Status - 300 <= 0 { statusColour = colour.Green } fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", after.Format(time.UnixDate), r.Method, r.URL.Path, - codeColour, - lrw.Code, + statusColour, + lrw.Status, colour.Reset, elapsed, r.Header["User-Agent"][0]) }) } - diff --git a/main.go b/main.go index 7962869..2f0cb43 100644 --- a/main.go +++ b/main.go @@ -7,22 +7,28 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "arimelody-web/admin" "arimelody-web/api" - "arimelody-web/global" - "arimelody-web/view" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/templates" + "arimelody-web/view" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) +// used for database migrations +const DB_VERSION = 1 + const DEFAULT_PORT int64 = 8080 func main() { + fmt.Printf("made with <3 by ari melody\n\n") + // initialise database connection if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } @@ -65,39 +71,107 @@ func main() { global.DB.SetMaxIdleConns(10) defer global.DB.Close() - _, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP") - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) - os.Exit(1) + // handle command arguments + if len(os.Args) > 1 { + arg := os.Args[1] + + switch arg { + case "createInvite": + fmt.Printf("Creating invite...\n") + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code) + return + + case "purgeInvites": + fmt.Printf("Deleting all invites...\n") + err := controller.DeleteAllInvites(global.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Invites deleted successfully.\n") + return + + case "deleteAccount": + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") + os.Exit(1) + } + username := os.Args[2] + fmt.Printf("Deleting account \"%s\"...\n", username) + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error()) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) + res := "" + fmt.Scanln(&res) + if !strings.HasPrefix(res, "y") { + return + } + + err = controller.DeleteAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) + return + + } + + fmt.Printf( + "Available commands:\n\n" + + "createInvite:\n\tCreates an invite code to register new accounts.\n" + + "purgeInvites:\n\tDeletes all available invite codes.\n" + + "deleteAccount :\n\tDeletes an account with a given `username`.\n", + ) + return } - accountsCount := 0 - global.DB.Get(&accountsCount, "SELECT count(*) FROM account") - if accountsCount == 0 { - code := controller.GenerateInviteCode(8) + // handle DB migrations + controller.CheckDBVersionAndMigrate(global.DB) - tx, err := global.DB.Begin() - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err) - os.Exit(1) - } - _, err = tx.Exec("DELETE FROM invite") + // initial invite code + accountsCount := 0 + err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") + if err != nil { panic(err) } + if accountsCount == 0 { + _, err := global.DB.Exec("DELETE FROM invite") if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) os.Exit(1) } - _, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute)) + + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) - os.Exit(1) - } - err = tx.Commit() - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) os.Exit(1) } - fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)") + fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl) + } + + // delete expired invites + err = controller.DeleteExpiredInvites(global.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) + os.Exit(1) } // start the web server! @@ -109,131 +183,6 @@ func main() { )) } -func initDB(driverName string, dataSourceName string) (*sqlx.DB, error) { - db, err := sqlx.Connect(driverName, dataSourceName) - if err != nil { return nil, err } - - // ensure tables exist - // account - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS account (" + - "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " + - "username text NOT NULL UNIQUE, " + - "password text NOT NULL, " + - "email text, " + - "avatar_url text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create account table: %s", err.Error())) } - - // privilege - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS privilege (" + - "account uuid NOT NULL, " + - "privilege text NOT NULL, " + - "CONSTRAINT privilege_pk PRIMARY KEY (account, privilege), " + - "CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create privilege table: %s", err.Error())) } - - // totp - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS totp (" + - "account uuid NOT NULL, " + - "name text NOT NULL, " + - "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - "CONSTRAINT totp_pk PRIMARY KEY (account, name), " + - "CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } - - // invites - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS invite (" + - "code text NOT NULL PRIMARY KEY, " + - "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - "expires_at TIMESTAMP NOT NULL)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } - - // artist - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS artist (" + - "id character varying(64) PRIMARY KEY, " + - "name text NOT NULL, " + - "website text, " + - "avatar text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create artist table: %s", err.Error())) } - - // musicrelease - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS musicrelease (" + - "id character varying(64) PRIMARY KEY, " + - "visible bool DEFAULT false, " + - "title text NOT NULL, " + - "description text, " + - "type text, " + - "release_date TIMESTAMP NOT NULL, " + - "artwork text, " + - "buyname text, " + - "buylink text, " + - "copyright text, " + - "copyrightURL text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicrelease table: %s", err.Error())) } - - // musiclink - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musiclink (" + - "release character varying(64) NOT NULL, " + - "name text NOT NULL, " + - "url text NOT NULL, " + - "CONSTRAINT musiclink_pk PRIMARY KEY (release, name), " + - "CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiclink table: %s", err.Error())) } - - // musiccredit - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musiccredit (" + - "release character varying(64) NOT NULL, " + - "artist character varying(64) NOT NULL, " + - "role text NOT NULL, " + - "is_primary boolean DEFAULT false, " + - "CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist), " + - "CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + - "CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiccredit table: %s", err.Error())) } - - // musictrack - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musictrack (" + - "id uuid DEFAULT gen_random_uuid() PRIMARY KEY, " + - "title text NOT NULL, " + - "description text, " + - "lyrics text, " + - "preview_url text)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musictrack table: %s", err.Error())) } - - // musicreleasetrack - _, err = db.Exec( - "CREATE TABLE IF NOT EXISTS public.musicreleasetrack (" + - "release character varying(64) NOT NULL, " + - "track uuid NOT NULL, " + - "number integer NOT NULL, " + - "CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track), " + - "CONSTRAINT musicreleasetrack_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + - "CONSTRAINT musicreleasetrack_artist_fk FOREIGN KEY (track) REFERENCES track(id) ON DELETE CASCADE)", - ) - if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicreleasetrack table: %s", err.Error())) } - - // TODO: automatic database migration - - return db, nil -} - func createServeMux() *http.ServeMux { mux := http.NewServeMux() diff --git a/model/account.go b/model/account.go index 16b0e3b..03e95c5 100644 --- a/model/account.go +++ b/model/account.go @@ -1,27 +1,16 @@ package model -import ( - "time" -) - type ( Account struct { ID string `json:"id" db:"id"` Username string `json:"username" db:"username"` - Password []byte `json:"password" db:"password"` + Password string `json:"password" db:"password"` Email string `json:"email" db:"email"` AvatarURL string `json:"avatar_url" db:"avatar_url"` Privileges []AccountPrivilege `json:"privileges"` } AccountPrivilege string - - Invite struct { - Code string `db:"code"` - CreatedByID string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - ExpiresAt time.Time `db:"expires_at"` - } ) const ( diff --git a/model/invite.go b/model/invite.go new file mode 100644 index 0000000..b7a66ae --- /dev/null +++ b/model/invite.go @@ -0,0 +1,10 @@ +package model + +import "time" + +type Invite struct { + Code string `db:"code"` + CreatedByID string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` +} diff --git a/model/token.go b/model/token.go new file mode 100644 index 0000000..31beb72 --- /dev/null +++ b/model/token.go @@ -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"` +} diff --git a/schema.sql b/schema_migration/000-init.sql similarity index 86% rename from schema.sql rename to schema_migration/000-init.sql index b5d92a3..cd11a5e 100644 --- a/schema.sql +++ b/schema_migration/000-init.sql @@ -1,8 +1,16 @@ -CREATE SCHEMA arimelody AUTHORIZATION arimelody; +CREATE SCHEMA arimelody; + +-- Schema verison +CREATE TABLE arimelody.schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT current_timestamp +); -- --- Acounts +-- Tables -- + +-- Accounts CREATE TABLE arimelody.account ( id uuid DEFAULT gen_random_uuid(), username text NOT NULL UNIQUE, @@ -12,18 +20,14 @@ CREATE TABLE arimelody.account ( ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); --- -- Privilege --- CREATE TABLE arimelody.privilege ( account uuid NOT NULL, privilege text NOT NULL ); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); --- -- TOTP --- CREATE TABLE arimelody.totp ( account uuid NOT NULL, name text NOT NULL, @@ -31,9 +35,7 @@ CREATE TABLE arimelody.totp ( ); ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); --- -- Invites --- CREATE TABLE arimelody.invite ( code text NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -41,9 +43,18 @@ CREATE TABLE arimelody.invite ( ); ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); --- +-- Tokens +CREATE TABLE arimelody.token ( + token TEXT, + account UUID NOT NULL, + user_agent TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP DEFAULT NULL +); +ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); + + -- Artists (should be applicable to all art) --- CREATE TABLE arimelody.artist ( id character varying(64), name text NOT NULL, @@ -52,9 +63,7 @@ CREATE TABLE arimelody.artist ( ); ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); --- -- Music releases --- CREATE TABLE arimelody.musicrelease ( id character varying(64) NOT NULL, visible bool DEFAULT false, @@ -70,9 +79,7 @@ CREATE TABLE arimelody.musicrelease ( ); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); --- -- Music links (external platform links under a release) --- CREATE TABLE arimelody.musiclink ( release character varying(64) NOT NULL, name text NOT NULL, @@ -80,9 +87,7 @@ CREATE TABLE arimelody.musiclink ( ); ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); --- -- Music credits (artist credits under a release) --- CREATE TABLE arimelody.musiccredit ( release character varying(64) NOT NULL, artist character varying(64) NOT NULL, @@ -91,9 +96,7 @@ CREATE TABLE arimelody.musiccredit ( ); ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); --- -- Music tracks (tracks under a release) --- CREATE TABLE arimelody.musictrack ( id uuid DEFAULT gen_random_uuid(), title text NOT NULL, @@ -103,9 +106,7 @@ CREATE TABLE arimelody.musictrack ( ); ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); --- -- Music release/track pairs --- CREATE TABLE arimelody.musicreleasetrack ( release character varying(64) NOT NULL, track uuid NOT NULL, @@ -113,11 +114,15 @@ CREATE TABLE arimelody.musicreleasetrack ( ); ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track); + + -- -- Foreign keys -- + ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql new file mode 100644 index 0000000..fc730a0 --- /dev/null +++ b/schema_migration/001-pre-versioning.sql @@ -0,0 +1,65 @@ +-- +-- Migration +-- + +-- Move existing tables to new schema +ALTER TABLE public.artist SET SCHEMA arimelody; +ALTER TABLE public.musicrelease SET SCHEMA arimelody; +ALTER TABLE public.musiclink SET SCHEMA arimelody; +ALTER TABLE public.musiccredit SET SCHEMA arimelody; +ALTER TABLE public.musictrack SET SCHEMA arimelody; +ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody; + + + +-- +-- New items +-- + +-- Acounts +CREATE TABLE arimelody.account ( + id uuid DEFAULT gen_random_uuid(), + username text NOT NULL UNIQUE, + password text NOT NULL, + email text, + avatar_url text +); +ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); + +-- Privilege +CREATE TABLE arimelody.privilege ( + account uuid NOT NULL, + privilege text NOT NULL +); +ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); + +-- TOTP +CREATE TABLE arimelody.totp ( + account uuid NOT NULL, + name text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + +-- Invites +CREATE TABLE arimelody.invite ( + code text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); +ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); + +-- Tokens +CREATE TABLE arimelody.token ( + token TEXT, + account UUID NOT NULL, + user_agent TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + expires_at TIMESTAMP DEFAULT NULL +); +ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); + +-- Foreign keys +ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; diff --git a/view/music.go b/view/music.go index f38e1c2..3799182 100644 --- a/view/music.go +++ b/view/music.go @@ -3,8 +3,8 @@ package view import ( "fmt" "net/http" + "os" - "arimelody-web/admin" "arimelody-web/controller" "arimelody-web/global" "arimelody-web/model" @@ -59,15 +59,28 @@ func ServeCatalog() http.Handler { func ServeGateway(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases - authorised := admin.GetSession(r) != nil - if !authorised && !release.Visible { - http.NotFound(w, r) - return + privileged := false + if !release.Visible { + account, err := controller.GetAccountByRequest(global.DB, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if account != nil { + // TODO: check privilege on release + privileged = true + } + + if !privileged { + http.NotFound(w, r) + return + } } response := *release - if authorised || release.IsReleased() { + if release.IsReleased() || privileged { response.Tracks = release.Tracks response.Credits = release.Credits response.Links = release.Links