home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

merged main, dev, and i guess got accounts working??

i am so good at commit messages :3

+1363 -395
+1 -1
.air.toml
··· 7 7 bin = "./tmp/main" 8 8 cmd = "go build -o ./tmp/main ." 9 9 delay = 1000 10 - exclude_dir = ["admin/static", "public", "uploads", "test", "db"] 10 + exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"] 11 11 exclude_file = [] 12 12 exclude_regex = ["_test.go"] 13 13 exclude_unchanged = false
+2 -1
.dockerignore
··· 3 3 .air.toml/ 4 4 .gitattributes 5 5 .gitignore 6 - uploads/* 6 + uploads/ 7 7 test/ 8 8 tmp/ 9 9 res/ 10 10 docker-compose.yml 11 + docker-compose-test.yml 11 12 Dockerfile 12 13 schema.sql
+4 -1
.gitignore
··· 4 4 tmp/ 5 5 test/ 6 6 uploads/ 7 - docker-compose-test.yml 7 + docker-compose*.yml 8 + !docker-compose.example.yml 9 + config*.toml 10 + >>>>>>> dev
+22 -11
README.md
··· 4 4 5 5 --- 6 6 7 - 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! 7 + built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static) 8 + branch, this powerful, server-side rendered version comes complete with live 9 + updates, powered by a new database and handy admin panel! 8 10 9 - 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! 11 + the admin panel currently facilitates live updating of my music discography, 12 + though i plan to expand it towards art portfolio and blog posts in the future. 13 + if all goes well, i'd like to later separate these components into their own 14 + library for others to use in their own sites. exciting stuff! 10 15 11 16 ## build 12 17 13 - easy! just `git clone` this repo and `go build` from the root. `arimelody-web(.exe)` should be generated. 18 + - `git clone` this repo, and `cd` into it. 19 + - `go build -o arimelody-web .` 14 20 15 21 ## running 16 22 17 - the webserver depends on some environment variables (don't worry about forgetting some; it'll be sure to bug you about them): 23 + the server should be run once to generate a default `config.toml` file. 24 + configure as needed. note that a valid DB connection is required, and the admin 25 + panel will be disabled without valid discord app credentials (this can however 26 + be bypassed by running the server with `-adminBypass`). 18 27 19 - - `HTTP_DOMAIN`: the domain the webserver will use for generating oauth redirect URIs (default `https://arimelody.me`) 20 - - `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) 21 - - `DISCORD_CLIENT`[^1]: the client ID of your discord OAuth application. 22 - - `DISCORD_SECRET`[^1]: the client secret of your discord OAuth application. 28 + the configuration may be overridden using environment variables in the format 29 + `ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may 30 + be overridden with `ARIMELODY_DB_HOST`. 23 31 24 - [^1]: not required, but the admin panel will be **disabled** if these are not provided. 32 + the location of the configuration file can also be overridden with 33 + `ARIMELODY_CONFIG`. 25 34 26 - the webserver requires a database to run. in this case, postgres. 35 + ## database 27 36 28 - 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 37 + the server requires a postgres database to run. you can use the 38 + [schema.sql](schema.sql) provided in this repo to generate the required tables. 39 + automatic schema building/migration may come in a future update.
+6 -19
admin/admin.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "math/rand" 6 - "os" 7 5 "time" 8 6 7 + "arimelody-web/controller" 9 8 "arimelody-web/global" 9 + "arimelody-web/model" 10 10 ) 11 11 12 12 type ( 13 13 Session struct { 14 14 Token string 15 - UserID string 15 + Account *model.Account 16 16 Expires time.Time 17 17 } 18 18 ) 19 19 20 20 const TOKEN_LENGTH = 64 21 - const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 22 21 23 22 var ADMIN_BYPASS = func() bool { 24 23 if global.Args["adminBypass"] == "true" { ··· 28 27 return false 29 28 }() 30 29 31 - var ADMIN_ID_DISCORD = os.Getenv("DISCORD_ADMIN") 32 - 33 30 var sessions []*Session 34 31 35 - func createSession(username string, expires time.Time) Session { 32 + func createSession(account *model.Account, expires time.Time) Session { 36 33 return Session{ 37 - Token: string(generateToken()), 38 - UserID: username, 34 + Token: string(controller.GenerateAlnumString(TOKEN_LENGTH)), 35 + Account: account, 39 36 Expires: expires, 40 37 } 41 38 } 42 - 43 - func generateToken() string { 44 - var token []byte 45 - 46 - for i := 0; i < TOKEN_LENGTH; i++ { 47 - token = append(token, TOKEN_CHARS[rand.Intn(len(TOKEN_CHARS))]) 48 - } 49 - 50 - return string(token) 51 - }
+11 -4
admin/artisthttp.go
··· 32 32 return 33 33 } 34 34 35 - type Artist struct { 36 - *model.Artist 37 - Credits []*model.Credit 35 + type ArtistResponse struct { 36 + Account *model.Account 37 + Artist *model.Artist 38 + Credits []*model.Credit 38 39 } 39 40 40 - err = pages["artist"].Execute(w, Artist{ Artist: artist, Credits: credits }) 41 + account := r.Context().Value("account").(*model.Account) 42 + 43 + err = pages["artist"].Execute(w, ArtistResponse{ 44 + Account: account, 45 + Artist: artist, 46 + Credits: credits, 47 + }) 41 48 if err != nil { 42 49 fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 43 50 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
-1
admin/components/credits/editcredits.html
··· 52 52 makeMagicList(creditList, ".credit"); 53 53 54 54 function rigCredit(el) { 55 - console.log(el); 56 55 const artistID = el.dataset.artist; 57 56 const deleteBtn = el.querySelector("a.delete"); 58 57
+4 -4
admin/components/tracks/edittracks.html
··· 12 12 13 13 <form action="/api/v1/music/{{.ID}}/tracks"> 14 14 <ul> 15 - {{range .Tracks}} 16 - <li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true"> 15 + {{range $i, $track := .Tracks}} 16 + <li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true"> 17 17 <div> 18 18 <p class="track-name"> 19 - <span class="track-number">{{.Number}}</span> 20 - {{.Title}} 19 + <span class="track-number">{{$track.Add $i 1}}</span> 20 + {{$track.Title}} 21 21 </p> 22 22 <a class="delete">Delete</a> 23 23 </div>
+120 -114
admin/http.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "arimelody-web/discord" 13 - "arimelody-web/global" 14 12 "arimelody-web/controller" 13 + "arimelody-web/global" 15 14 "arimelody-web/model" 15 + 16 + "github.com/jmoiron/sqlx" 17 + "golang.org/x/crypto/bcrypt" 16 18 ) 17 19 18 - type loginData struct { 19 - DiscordURI string 20 + type TemplateData struct { 21 + Account *model.Account 20 22 Token string 21 23 } 22 24 ··· 25 27 26 28 mux.Handle("/login", LoginHandler()) 27 29 mux.Handle("/create-account", createAccountHandler()) 28 - mux.Handle("/logout", MustAuthorise(LogoutHandler())) 30 + mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) 29 31 mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) 30 - mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) 31 - mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist()))) 32 - mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack()))) 32 + mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease()))) 33 + mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) 34 + mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack()))) 33 35 mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 36 if r.URL.Path != "/" { 35 37 http.NotFound(w, r) 36 38 return 37 39 } 38 40 39 - session := GetSession(r) 40 - if session == nil { 41 + account, err := controller.GetAccountByRequest(global.DB, r) 42 + if err != nil { 43 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) 44 + } 45 + if account == nil { 41 46 http.Redirect(w, r, "/admin/login", http.StatusFound) 42 47 return 43 48 } 44 49 45 50 releases, err := controller.GetAllReleases(global.DB, false, 0, true) 46 51 if err != nil { 47 - fmt.Printf("FATAL: Failed to pull releases: %s\n", err) 52 + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) 48 53 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 49 54 return 50 55 } 51 56 52 57 artists, err := controller.GetAllArtists(global.DB) 53 58 if err != nil { 54 - fmt.Printf("FATAL: Failed to pull artists: %s\n", err) 59 + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) 55 60 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 56 61 return 57 62 } 58 63 59 64 tracks, err := controller.GetOrphanTracks(global.DB) 60 65 if err != nil { 61 - fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err) 66 + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) 62 67 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 63 68 return 64 69 } 65 70 66 71 type IndexData struct { 72 + Account *model.Account 67 73 Releases []*model.Release 68 74 Artists []*model.Artist 69 75 Tracks []*model.Track 70 76 } 71 77 72 78 err = pages["index"].Execute(w, IndexData{ 79 + Account: account, 73 80 Releases: releases, 74 81 Artists: artists, 75 82 Tracks: tracks, 76 83 }) 77 84 if err != nil { 78 - fmt.Printf("Error executing template: %s\n", err) 85 + fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) 79 86 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 80 87 return 81 88 } ··· 84 91 return mux 85 92 } 86 93 87 - func MustAuthorise(next http.Handler) http.Handler { 94 + func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { 88 95 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 - session := GetSession(r) 90 - if session == nil { 91 - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 96 + account, err := controller.GetAccountByRequest(db, r) 97 + if err != nil { 98 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 99 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 100 + return 101 + } 102 + if account == nil { 103 + // TODO: include context in redirect 104 + http.Redirect(w, r, "/admin/login", http.StatusFound) 92 105 return 93 106 } 94 107 95 - ctx := context.WithValue(r.Context(), "session", session) 108 + ctx := context.WithValue(r.Context(), "account", account) 109 + 96 110 next.ServeHTTP(w, r.WithContext(ctx)) 97 111 }) 98 112 } 99 113 100 - func GetSession(r *http.Request) *Session { 101 - if ADMIN_BYPASS { 102 - return &Session{} 103 - } 114 + func LoginHandler() http.Handler { 115 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 116 + if r.Method == http.MethodGet { 117 + account, err := controller.GetAccountByRequest(global.DB, r) 118 + if err != nil { 119 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 120 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 121 + return 122 + } 123 + if account != nil { 124 + http.Redirect(w, r, "/admin", http.StatusFound) 125 + return 126 + } 104 127 105 - var token = "" 106 - // is the session token in context? 107 - var ctx_session = r.Context().Value("session") 108 - if ctx_session != nil { 109 - token = ctx_session.(*Session).Token 110 - } 128 + err = pages["login"].Execute(w, TemplateData{}) 129 + if err != nil { 130 + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) 131 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 132 + return 133 + } 134 + return 135 + } 111 136 112 - // okay, is it in the auth header? 113 - if token == "" { 114 - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { 115 - token = r.Header.Get("Authorization")[7:] 137 + if r.Method != http.MethodPost { 138 + http.NotFound(w, r); 139 + return 116 140 } 117 - } 118 - // finally, is it in the cookie? 119 - if token == "" { 120 - cookie, err := r.Cookie("token") 141 + 142 + err := r.ParseForm() 121 143 if err != nil { 122 - return nil 144 + fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err) 145 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 146 + return 123 147 } 124 - token = cookie.Value 125 - } 126 148 127 - var session *Session = nil 128 - for _, s := range sessions { 129 - if s.Expires.Before(time.Now()) { 130 - // expired session. remove it from the list! 131 - new_sessions := []*Session{} 132 - for _, ns := range sessions { 133 - if ns.Token == s.Token { 134 - continue 135 - } 136 - new_sessions = append(new_sessions, ns) 137 - } 138 - sessions = new_sessions 139 - continue 149 + type LoginRequest struct { 150 + Username string `json:"username"` 151 + Password string `json:"password"` 152 + TOTP string `json:"totp"` 140 153 } 141 - 142 - if s.Token == token { 143 - session = s 144 - break 154 + data := LoginRequest{ 155 + Username: r.Form.Get("username"), 156 + Password: r.Form.Get("password"), 157 + TOTP: r.Form.Get("totp"), 145 158 } 146 - } 147 159 148 - return session 149 - } 150 - 151 - func LoginHandler() http.Handler { 152 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 153 - // if !discord.CREDENTIALS_PROVIDED || ADMIN_ID_DISCORD == "" { 154 - // http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) 155 - // return 156 - // } 157 - // 158 - // fmt.Println(discord.CLIENT_ID) 159 - // fmt.Println(discord.API_ENDPOINT) 160 - // fmt.Println(discord.REDIRECT_URI) 161 - 162 - code := r.URL.Query().Get("code") 163 - 164 - if code == "" { 165 - pages["login"].Execute(w, loginData{DiscordURI: discord.REDIRECT_URI}) 160 + account, err := controller.GetAccount(global.DB, data.Username) 161 + if err != nil { 162 + http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) 166 163 return 167 164 } 168 165 169 - auth_token, err := discord.GetOAuthTokenFromCode(code) 166 + err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password)) 170 167 if err != nil { 171 - fmt.Printf("Failed to retrieve discord access token: %s\n", err) 172 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 168 + http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) 173 169 return 174 170 } 175 171 176 - discord_user, err := discord.GetDiscordUserFromAuth(auth_token) 172 + // TODO: check TOTP 173 + 174 + // login success! 175 + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) 177 176 if err != nil { 178 - fmt.Printf("Failed to retrieve discord user information: %s\n", err) 177 + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %s\n", err.Error()) 179 178 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 180 179 return 181 180 } 182 181 183 - if discord_user.ID != ADMIN_ID_DISCORD { 184 - // TODO: unauthorized user; revoke the token 185 - fmt.Printf("Unauthorized login attempted: %s\n", discord_user.ID) 186 - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 187 - return 188 - } 189 - 190 - // login success! 191 - session := createSession(discord_user.Username, time.Now().Add(24 * time.Hour)) 192 - sessions = append(sessions, &session) 193 - 194 182 cookie := http.Cookie{} 195 - cookie.Name = "token" 196 - cookie.Value = session.Token 197 - cookie.Expires = time.Now().Add(24 * time.Hour) 198 - if strings.HasPrefix(global.HTTP_DOMAIN, "https") { 183 + cookie.Name = global.COOKIE_TOKEN 184 + cookie.Value = token.Token 185 + cookie.Expires = token.ExpiresAt 186 + if strings.HasPrefix(global.Config.BaseUrl, "https") { 199 187 cookie.Secure = true 200 188 } 201 189 cookie.HttpOnly = true 202 190 cookie.Path = "/" 203 191 http.SetCookie(w, &cookie) 204 192 205 - err = pages["login"].Execute(w, loginData{Token: session.Token}) 193 + err = pages["login"].Execute(w, TemplateData{ 194 + Account: account, 195 + Token: token.Token, 196 + }) 206 197 if err != nil { 207 198 fmt.Printf("Error rendering admin login page: %s\n", err) 208 199 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 218 209 return 219 210 } 220 211 221 - session := GetSession(r) 212 + token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 222 213 223 - // remove this session from the list 224 - sessions = func (token string) []*Session { 225 - new_sessions := []*Session{} 226 - for _, session := range sessions { 227 - if session.Token != token { 228 - new_sessions = append(new_sessions, session) 229 - } 214 + if token_str == "" { 215 + cookie, err := r.Cookie(global.COOKIE_TOKEN) 216 + if err != nil { 217 + fmt.Fprintf(os.Stderr, "WARN: Error fetching token cookie: %s\n", err) 218 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 219 + return 230 220 } 231 - return new_sessions 232 - }(session.Token) 221 + if cookie != nil { 222 + token_str = cookie.Value 223 + } 224 + } 233 225 234 - err := pages["logout"].Execute(w, nil) 235 - if err != nil { 236 - fmt.Printf("Error rendering admin logout page: %s\n", err) 237 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 238 - return 226 + if len(token_str) > 0 { 227 + err := controller.DeleteToken(global.DB, token_str) 228 + if err != nil { 229 + fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %s\n", err.Error()) 230 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 231 + return 232 + } 233 + } 234 + 235 + cookie := http.Cookie{} 236 + cookie.Name = global.COOKIE_TOKEN 237 + cookie.Value = "" 238 + cookie.Expires = time.Now() 239 + if strings.HasPrefix(global.Config.BaseUrl, "https") { 240 + cookie.Secure = true 239 241 } 242 + cookie.HttpOnly = true 243 + cookie.Path = "/" 244 + http.SetCookie(w, &cookie) 245 + http.Redirect(w, r, "/admin/login", http.StatusFound) 240 246 }) 241 247 } 242 248 243 249 func createAccountHandler() http.Handler { 244 250 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 - err := pages["create-account"].Execute(w, nil) 251 + err := pages["create-account"].Execute(w, TemplateData{}) 246 252 if err != nil { 247 - fmt.Printf("Error rendering admin crearte account page: %s\n", err) 253 + fmt.Printf("Error rendering create account page: %s\n", err) 248 254 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 249 255 return 250 256 }
+11 -7
admin/releasehttp.go
··· 15 15 slices := strings.Split(r.URL.Path[1:], "/") 16 16 releaseID := slices[0] 17 17 18 + account := r.Context().Value("account").(*model.Account) 19 + 18 20 release, err := controller.GetRelease(global.DB, releaseID, true) 19 21 if err != nil { 20 22 if strings.Contains(err.Error(), "no rows") { ··· 23 25 } 24 26 fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err) 25 27 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 26 - return 27 - } 28 - 29 - authorised := GetSession(r) != nil 30 - if !authorised && !release.Visible { 31 - http.NotFound(w, r) 32 28 return 33 29 } 34 30 ··· 60 56 return 61 57 } 62 58 63 - err = pages["release"].Execute(w, release) 59 + type ReleaseResponse struct { 60 + Account *model.Account 61 + Release *model.Release 62 + } 63 + 64 + err = pages["release"].Execute(w, ReleaseResponse{ 65 + Account: account, 66 + Release: release, 67 + }) 64 68 if err != nil { 65 69 fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) 66 70 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+11 -6
admin/static/admin.css
··· 43 43 44 44 color: inherit; 45 45 } 46 - nav a { 46 + .nav-item { 47 47 width: auto; 48 48 height: 100%; 49 49 ··· 53 53 display: flex; 54 54 55 55 line-height: 2em; 56 + } 57 + .nav-item:hover { 58 + background: #00000010; 56 59 text-decoration: none; 57 - 58 - color: inherit; 59 60 } 60 - nav a:hover { 61 - background: #00000010; 61 + nav a { 62 62 text-decoration: none; 63 + color: inherit; 63 64 } 64 65 nav #logout { 65 - margin-left: auto; 66 + /* margin-left: auto; */ 66 67 } 67 68 68 69 main { ··· 112 113 .card-title h2, 113 114 .card-title h3 { 114 115 margin: 0; 116 + } 117 + 118 + .flex-fill { 119 + flex-grow: 1; 115 120 } 116 121 117 122 @media screen and (max-width: 520px) {
+10 -3
admin/trackhttp.go
··· 32 32 return 33 33 } 34 34 35 - type Track struct { 36 - *model.Track 35 + type TrackResponse struct { 36 + Account *model.Account 37 + Track *model.Track 37 38 Releases []*model.Release 38 39 } 39 40 40 - err = pages["track"].Execute(w, Track{ Track: track, Releases: releases }) 41 + account := r.Context().Value("account").(*model.Account) 42 + 43 + err = pages["track"].Execute(w, TrackResponse{ 44 + Account: account, 45 + Track: track, 46 + Releases: releases, 47 + }) 41 48 if err != nil { 42 49 fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 43 50 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+11 -10
admin/views/edit-artist.html
··· 1 1 {{define "head"}} 2 - <title>Editing {{.Name}} - ari melody 💫</title> 2 + <title>Editing {{.Artist.Name}} - ari melody 💫</title> 3 + <link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon"> 3 4 4 5 <link rel="stylesheet" href="/admin/static/edit-artist.css"> 5 6 {{end}} ··· 8 9 <main> 9 10 <h1>Editing Artist</h1> 10 11 11 - <div id="artist" data-id="{{.ID}}"> 12 + <div id="artist" data-id="{{.Artist.ID}}"> 12 13 <div class="artist-avatar"> 13 - <img src="{{.Avatar}}" alt="" width="256" loading="lazy" id="avatar"> 14 + <img src="{{.Artist.Avatar}}" alt="" width="256" loading="lazy" id="avatar"> 14 15 <input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden> 15 16 <button id="remove-avatar">Remove</button> 16 17 </div> 17 18 <div class="artist-info"> 18 19 <p class="attribute-header">Name</p> 19 20 <h2 class="artist-name"> 20 - <input type="text" id="name" name="artist-name" value="{{.Name}}"> 21 + <input type="text" id="name" name="artist-name" value="{{.Artist.Name}}"> 21 22 </h2> 22 23 23 24 <p class="attribute-header">Website</p> 24 - <input type="text" id="website" name="website" value="{{.Website}}"> 25 + <input type="text" id="website" name="website" value="{{.Artist.Website}}"> 25 26 26 27 <div class="artist-actions"> 27 28 <button type="submit" class="save" id="save" disabled>Save</button> ··· 36 37 {{if .Credits}} 37 38 {{range .Credits}} 38 39 <div class="credit"> 39 - <img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> 40 + <img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> 40 41 <div class="credit-info"> 41 - <h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3> 42 - <p class="credit-artists">{{.Release.PrintArtists true true}}</p> 42 + <h3 class="credit-name"><a href="/admin/release/{{.Artist.Release.ID}}">{{.Artist.Release.Title}}</a></h3> 43 + <p class="credit-artists">{{.Artist.Release.PrintArtists true true}}</p> 43 44 <p class="artist-role"> 44 - Role: {{.Role}} 45 - {{if .Primary}} 45 + Role: {{.Artist.Role}} 46 + {{if .Artist.Primary}} 46 47 <small>(Primary)</small> 47 48 {{end}} 48 49 </p>
+29 -29
admin/views/edit-release.html
··· 1 1 {{define "head"}} 2 - <title>Editing {{.Title}} - ari melody 💫</title> 3 - <link rel="shortcut icon" href="{{.GetArtwork}}" type="image/x-icon"> 2 + <title>Editing {{.Release.Title}} - ari melody 💫</title> 3 + <link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon"> 4 4 5 5 <link rel="stylesheet" href="/admin/static/edit-release.css"> 6 6 {{end}} ··· 8 8 {{define "content"}} 9 9 <main> 10 10 11 - <div id="release" data-id="{{.ID}}"> 11 + <div id="release" data-id="{{.Release.ID}}"> 12 12 <div class="release-artwork"> 13 - <img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork"> 13 + <img src="{{.Release.Artwork}}" alt="" width="256" loading="lazy" id="artwork"> 14 14 <input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden> 15 15 <button id="remove-artwork">Remove</button> 16 16 </div> 17 17 <div class="release-info"> 18 18 <h1 class="release-title"> 19 - <input type="text" id="title" name="Title" value="{{.Title}}" autocomplete="on"> 19 + <input type="text" id="title" name="Title" value="{{.Release.Title}}" autocomplete="on"> 20 20 </h1> 21 21 <table> 22 22 <tr> 23 23 <td>Type</td> 24 24 <td> 25 - {{$t := .ReleaseType}} 25 + {{$t := .Release.ReleaseType}} 26 26 <select name="Type" id="type"> 27 27 <option value="single" {{if eq $t "single"}}selected{{end}}> 28 28 Single ··· 44 44 <td> 45 45 <textarea 46 46 name="Description" 47 - value="{{.Description}}" 47 + value="{{.Release.Description}}" 48 48 placeholder="No description provided." 49 49 rows="3" 50 50 id="description" 51 - >{{.Description}}</textarea> 51 + >{{.Release.Description}}</textarea> 52 52 </td> 53 53 </tr> 54 54 <tr> 55 55 <td>Release Date</td> 56 56 <td> 57 - <input type="datetime-local" name="release-date" id="release-date" value="{{.TextReleaseDate}}"> 57 + <input type="datetime-local" name="release-date" id="release-date" value="{{.Release.TextReleaseDate}}"> 58 58 </td> 59 59 </tr> 60 60 <tr> 61 61 <td>Buy Name</td> 62 62 <td> 63 - <input type="text" name="buyname" id="buyname" value="{{.Buyname}}" autocomplete="on"> 63 + <input type="text" name="buyname" id="buyname" value="{{.Release.Buyname}}" autocomplete="on"> 64 64 </td> 65 65 </tr> 66 66 <tr> 67 67 <td>Buy Link</td> 68 68 <td> 69 - <input type="text" name="buylink" id="buylink" value="{{.Buylink}}" autocomplete="on"> 69 + <input type="text" name="buylink" id="buylink" value="{{.Release.Buylink}}" autocomplete="on"> 70 70 </td> 71 71 </tr> 72 72 <tr> 73 73 <td>Copyright</td> 74 74 <td> 75 - <input type="text" name="copyright" id="copyright" value="{{.Copyright}}" autocomplete="on"> 75 + <input type="text" name="copyright" id="copyright" value="{{.Release.Copyright}}" autocomplete="on"> 76 76 </td> 77 77 </tr> 78 78 <tr> 79 79 <td>Copyright URL</td> 80 80 <td> 81 - <input type="text" name="copyright-url" id="copyright-url" value="{{.CopyrightURL}}" autocomplete="on"> 81 + <input type="text" name="copyright-url" id="copyright-url" value="{{.Release.CopyrightURL}}" autocomplete="on"> 82 82 </td> 83 83 </tr> 84 84 <tr> 85 85 <td>Visible</td> 86 86 <td> 87 87 <select name="Visibility" id="visibility"> 88 - <option value="true" {{if .Visible}}selected{{end}}>True</option> 89 - <option value="false" {{if not .Visible}}selected{{end}}>False</option> 88 + <option value="true" {{if .Release.Visible}}selected{{end}}>True</option> 89 + <option value="false" {{if not .Release.Visible}}selected{{end}}>False</option> 90 90 </select> 91 91 </td> 92 92 </tr> 93 93 </table> 94 94 <div class="release-actions"> 95 - <a href="/music/{{.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a> 95 + <a href="/music/{{.Release.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a> 96 96 <button type="submit" class="save" id="save" disabled>Save</button> 97 97 </div> 98 98 </div> 99 99 </div> 100 100 101 101 <div class="card-title"> 102 - <h2>Credits ({{len .Credits}})</h2> 102 + <h2>Credits ({{len .Release.Credits}})</h2> 103 103 <a class="button edit" 104 - href="/admin/release/{{.ID}}/editcredits" 105 - hx-get="/admin/release/{{.ID}}/editcredits" 104 + href="/admin/release/{{.Release.ID}}/editcredits" 105 + hx-get="/admin/release/{{.Release.ID}}/editcredits" 106 106 hx-target="body" 107 107 hx-swap="beforeend" 108 108 >Edit</a> 109 109 </div> 110 110 <div class="card credits"> 111 - {{range .Credits}} 111 + {{range .Release.Credits}} 112 112 <div class="credit"> 113 113 <img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> 114 114 <div class="credit-info"> ··· 122 122 </div> 123 123 </div> 124 124 {{end}} 125 - {{if not .Credits}} 125 + {{if not .Release.Credits}} 126 126 <p>There are no credits.</p> 127 127 {{end}} 128 128 </div> 129 129 130 130 <div class="card-title"> 131 - <h2>Links ({{len .Links}})</h2> 131 + <h2>Links ({{len .Release.Links}})</h2> 132 132 <a class="button edit" 133 - href="/admin/release/{{.ID}}/editlinks" 134 - hx-get="/admin/release/{{.ID}}/editlinks" 133 + href="/admin/release/{{.Release.ID}}/editlinks" 134 + hx-get="/admin/release/{{.Release.ID}}/editlinks" 135 135 hx-target="body" 136 136 hx-swap="beforeend" 137 137 >Edit</a> 138 138 </div> 139 139 <div class="card links"> 140 - {{range .Links}} 140 + {{range .Release.Links}} 141 141 <a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a> 142 142 {{end}} 143 143 </div> 144 144 145 145 <div class="card-title" id="tracks"> 146 - <h2>Tracklist ({{len .Tracks}})</h2> 146 + <h2>Tracklist ({{len .Release.Tracks}})</h2> 147 147 <a class="button edit" 148 - href="/admin/release/{{.ID}}/edittracks" 149 - hx-get="/admin/release/{{.ID}}/edittracks" 148 + href="/admin/release/{{.Release.ID}}/edittracks" 149 + hx-get="/admin/release/{{.Release.ID}}/edittracks" 150 150 hx-target="body" 151 151 hx-swap="beforeend" 152 152 >Edit</a> 153 153 </div> 154 154 <div class="card tracks"> 155 - {{range $i, $track := .Tracks}} 155 + {{range $i, $track := .Release.Tracks}} 156 156 <div class="track" data-id="{{$track.ID}}"> 157 157 <h2 class="track-title"> 158 158 <span class="track-number">{{.Add $i 1}}</span>
+6 -6
admin/views/edit-track.html
··· 8 8 <main> 9 9 <h1>Editing Track</h1> 10 10 11 - <div id="track" data-id="{{.ID}}"> 11 + <div id="track" data-id="{{.Track.ID}}"> 12 12 <div class="track-info"> 13 13 <p class="attribute-header">Title</p> 14 14 <h2 class="track-title"> 15 - <input type="text" id="title" name="Title" value="{{.Title}}"> 15 + <input type="text" id="title" name="Title" value="{{.Track.Title}}"> 16 16 </h2> 17 17 18 18 <p class="attribute-header">Description</p> 19 19 <textarea 20 20 name="Description" 21 - value="{{.Description}}" 21 + value="{{.Track.Description}}" 22 22 placeholder="No description provided." 23 23 rows="5" 24 24 id="description" 25 - >{{.Description}}</textarea> 25 + >{{.Track.Description}}</textarea> 26 26 27 27 <p class="attribute-header">Lyrics</p> 28 28 <textarea 29 29 name="Lyrics" 30 - value="{{.Lyrics}}" 30 + value="{{.Track.Lyrics}}" 31 31 placeholder="There are no lyrics." 32 32 rows="5" 33 33 id="lyrics" 34 - >{{.Lyrics}}</textarea> 34 + >{{.Track.Lyrics}}</textarea> 35 35 36 36 <div class="track-actions"> 37 37 <button type="submit" class="save" id="save" disabled>Save</button>
+12 -3
admin/views/layout.html
··· 17 17 <header> 18 18 <nav> 19 19 <img src="/img/favicon.png" alt="" class="icon"> 20 - <a href="/">arimelody.me</a> 21 - <a href="/admin">home</a> 22 - <a href="/admin/logout" id="logout">log out</a> 20 + <div class="nav-item"> 21 + <a href="/">arimelody.me</a> 22 + </div> 23 + <div class="nav-item"> 24 + <a href="/admin">home</a> 25 + </div> 26 + <div class="flex-fill"></div> 27 + {{if .Account}} 28 + <div class="nav-item"> 29 + <a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a> 30 + </div> 31 + {{end}} 23 32 </nav> 24 33 </header> 25 34
+4 -6
admin/views/login.html
··· 72 72 <main> 73 73 {{if .Token}} 74 74 75 - <meta http-equiv="refresh" content="5;url=/admin/" /> 75 + <meta http-equiv="refresh" content="0;url=/admin/" /> 76 76 <p> 77 77 Logged in successfully. 78 - You should be redirected to <a href="/admin">/admin</a> in 5 seconds. 78 + You should be redirected to <a href="/admin">/admin</a> soon. 79 79 </p> 80 80 81 81 {{else}} 82 82 83 - <!-- <p>Log in with <a href="{{.DiscordURI}}" class="discord">Discord</a>.</p> --> 84 - 85 83 <form action="/admin/login" method="POST" id="login"> 86 84 <div> 87 85 <label for="username">Username</label> ··· 90 88 <label for="password">Password</label> 91 89 <input type="password" name="password" value=""> 92 90 93 - <label for="code">Code</label> 94 - <input type="text" name="code" value=""> 91 + <label for="totp">TOTP</label> 92 + <input type="text" name="totp" value=""> 95 93 </div> 96 94 97 95 <button type="submit" class="save">Login</button>
+175
api/account.go
··· 1 + package api 2 + 3 + import ( 4 + "arimelody-web/controller" 5 + "arimelody-web/model" 6 + "arimelody-web/global" 7 + "encoding/json" 8 + "fmt" 9 + "net/http" 10 + "os" 11 + "strings" 12 + "time" 13 + 14 + "golang.org/x/crypto/bcrypt" 15 + ) 16 + 17 + func handleLogin() http.HandlerFunc { 18 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 + if r.Method != http.MethodPost { 20 + http.NotFound(w, r) 21 + return 22 + } 23 + 24 + type LoginRequest struct { 25 + Username string `json:"username"` 26 + Password string `json:"password"` 27 + } 28 + 29 + credentials := LoginRequest{} 30 + err := json.NewDecoder(r.Body).Decode(&credentials) 31 + if err != nil { 32 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 33 + return 34 + } 35 + 36 + account, err := controller.GetAccount(global.DB, credentials.Username) 37 + if err != nil { 38 + if strings.Contains(err.Error(), "no rows") { 39 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 40 + return 41 + } 42 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) 43 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 44 + return 45 + } 46 + 47 + err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) 48 + if err != nil { 49 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 50 + return 51 + } 52 + 53 + // TODO: sessions and tokens 54 + 55 + w.WriteHeader(http.StatusOK) 56 + w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) 57 + }) 58 + } 59 + 60 + func handleAccountRegistration() http.HandlerFunc { 61 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 + if r.Method != http.MethodPost { 63 + http.NotFound(w, r) 64 + return 65 + } 66 + 67 + type RegisterRequest struct { 68 + Username string `json:"username"` 69 + Email string `json:"email"` 70 + Password string `json:"password"` 71 + Code string `json:"code"` 72 + } 73 + 74 + credentials := RegisterRequest{} 75 + err := json.NewDecoder(r.Body).Decode(&credentials) 76 + if err != nil { 77 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 78 + return 79 + } 80 + 81 + // make sure code exists in DB 82 + invite := model.Invite{} 83 + err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code) 84 + if err != nil { 85 + if strings.Contains(err.Error(), "no rows") { 86 + http.Error(w, "Invalid invite code", http.StatusBadRequest) 87 + return 88 + } 89 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %s\n", err.Error()) 90 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 91 + return 92 + } 93 + 94 + if time.Now().After(invite.ExpiresAt) { 95 + http.Error(w, "Invalid invite code", http.StatusBadRequest) 96 + _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) 97 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } 98 + return 99 + } 100 + 101 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) 102 + if err != nil { 103 + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error()) 104 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 105 + return 106 + } 107 + 108 + account := model.Account{ 109 + Username: credentials.Username, 110 + Password: hashedPassword, 111 + Email: credentials.Email, 112 + AvatarURL: "/img/default-avatar.png", 113 + } 114 + err = controller.CreateAccount(global.DB, &account) 115 + if err != nil { 116 + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error()) 117 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 118 + return 119 + } 120 + 121 + _, err = global.DB.Exec("DELETE FROM invite WHERE code=$1", credentials.Code) 122 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %s\n", err.Error()) } 123 + 124 + w.WriteHeader(http.StatusCreated) 125 + w.Write([]byte("Account created successfully\n")) 126 + }) 127 + } 128 + 129 + func handleDeleteAccount() http.HandlerFunc { 130 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 131 + if r.Method != http.MethodPost { 132 + http.NotFound(w, r) 133 + return 134 + } 135 + 136 + type LoginRequest struct { 137 + Username string `json:"username"` 138 + Password string `json:"password"` 139 + } 140 + 141 + credentials := LoginRequest{} 142 + err := json.NewDecoder(r.Body).Decode(&credentials) 143 + if err != nil { 144 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 145 + return 146 + } 147 + 148 + account, err := controller.GetAccount(global.DB, credentials.Username) 149 + if err != nil { 150 + if strings.Contains(err.Error(), "no rows") { 151 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 152 + return 153 + } 154 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) 155 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 156 + return 157 + } 158 + 159 + err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) 160 + if err != nil { 161 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 162 + return 163 + } 164 + 165 + err = controller.DeleteAccount(global.DB, account.ID) 166 + if err != nil { 167 + fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error()) 168 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 169 + return 170 + } 171 + 172 + w.WriteHeader(http.StatusOK) 173 + w.Write([]byte("Account deleted successfully\n")) 174 + }) 175 + }
+17 -11
api/api.go
··· 13 13 func Handler() http.Handler { 14 14 mux := http.NewServeMux() 15 15 16 + // ACCOUNT ENDPOINTS 17 + 18 + mux.Handle("/v1/login", handleLogin()) 19 + mux.Handle("/v1/register", handleAccountRegistration()) 20 + mux.Handle("/v1/delete-account", handleDeleteAccount()) 21 + 16 22 // ARTIST ENDPOINTS 17 23 18 24 mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ··· 34 40 ServeArtist(artist).ServeHTTP(w, r) 35 41 case http.MethodPut: 36 42 // PUT /api/v1/artist/{id} (admin) 37 - admin.MustAuthorise(UpdateArtist(artist)).ServeHTTP(w, r) 43 + admin.RequireAccount(global.DB, UpdateArtist(artist)).ServeHTTP(w, r) 38 44 case http.MethodDelete: 39 45 // DELETE /api/v1/artist/{id} (admin) 40 - admin.MustAuthorise(DeleteArtist(artist)).ServeHTTP(w, r) 46 + admin.RequireAccount(global.DB, DeleteArtist(artist)).ServeHTTP(w, r) 41 47 default: 42 48 http.NotFound(w, r) 43 49 } ··· 49 55 ServeAllArtists().ServeHTTP(w, r) 50 56 case http.MethodPost: 51 57 // POST /api/v1/artist (admin) 52 - admin.MustAuthorise(CreateArtist()).ServeHTTP(w, r) 58 + admin.RequireAccount(global.DB, CreateArtist()).ServeHTTP(w, r) 53 59 default: 54 60 http.NotFound(w, r) 55 61 } ··· 76 82 ServeRelease(release).ServeHTTP(w, r) 77 83 case http.MethodPut: 78 84 // PUT /api/v1/music/{id} (admin) 79 - admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) 85 + admin.RequireAccount(global.DB, UpdateRelease(release)).ServeHTTP(w, r) 80 86 case http.MethodDelete: 81 87 // DELETE /api/v1/music/{id} (admin) 82 - admin.MustAuthorise(DeleteRelease(release)).ServeHTTP(w, r) 88 + admin.RequireAccount(global.DB, DeleteRelease(release)).ServeHTTP(w, r) 83 89 default: 84 90 http.NotFound(w, r) 85 91 } ··· 91 97 ServeCatalog().ServeHTTP(w, r) 92 98 case http.MethodPost: 93 99 // POST /api/v1/music (admin) 94 - admin.MustAuthorise(CreateRelease()).ServeHTTP(w, r) 100 + admin.RequireAccount(global.DB, CreateRelease()).ServeHTTP(w, r) 95 101 default: 96 102 http.NotFound(w, r) 97 103 } ··· 115 121 switch r.Method { 116 122 case http.MethodGet: 117 123 // GET /api/v1/track/{id} (admin) 118 - admin.MustAuthorise(ServeTrack(track)).ServeHTTP(w, r) 124 + admin.RequireAccount(global.DB, ServeTrack(track)).ServeHTTP(w, r) 119 125 case http.MethodPut: 120 126 // PUT /api/v1/track/{id} (admin) 121 - admin.MustAuthorise(UpdateTrack(track)).ServeHTTP(w, r) 127 + admin.RequireAccount(global.DB, UpdateTrack(track)).ServeHTTP(w, r) 122 128 case http.MethodDelete: 123 129 // DELETE /api/v1/track/{id} (admin) 124 - admin.MustAuthorise(DeleteTrack(track)).ServeHTTP(w, r) 130 + admin.RequireAccount(global.DB, DeleteTrack(track)).ServeHTTP(w, r) 125 131 default: 126 132 http.NotFound(w, r) 127 133 } ··· 130 136 switch r.Method { 131 137 case http.MethodGet: 132 138 // GET /api/v1/track (admin) 133 - admin.MustAuthorise(ServeAllTracks()).ServeHTTP(w, r) 139 + admin.RequireAccount(global.DB, ServeAllTracks()).ServeHTTP(w, r) 134 140 case http.MethodPost: 135 141 // POST /api/v1/track (admin) 136 - admin.MustAuthorise(CreateTrack()).ServeHTTP(w, r) 142 + admin.RequireAccount(global.DB, CreateTrack()).ServeHTTP(w, r) 137 143 default: 138 144 http.NotFound(w, r) 139 145 }
+19 -11
api/artist.go
··· 10 10 "strings" 11 11 "time" 12 12 13 - "arimelody-web/admin" 14 13 "arimelody-web/global" 15 14 "arimelody-web/controller" 16 15 "arimelody-web/model" ··· 21 20 var artists = []*model.Artist{} 22 21 artists, err := controller.GetAllArtists(global.DB) 23 22 if err != nil { 24 - fmt.Printf("FATAL: Failed to serve all artists: %s\n", err) 23 + fmt.Printf("WARN: Failed to serve all artists: %s\n", err) 25 24 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 26 25 return 27 26 } 28 27 29 28 w.Header().Add("Content-Type", "application/json") 30 - err = json.NewEncoder(w).Encode(artists) 29 + encoder := json.NewEncoder(w) 30 + encoder.SetIndent("", "\t") 31 + err = encoder.Encode(artists) 31 32 if err != nil { 32 33 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 33 34 } ··· 51 52 } 52 53 ) 53 54 54 - show_hidden_releases := admin.GetSession(r) != nil 55 + account, err := controller.GetAccountByRequest(global.DB, r) 56 + if err != nil { 57 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 58 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 59 + return 60 + } 61 + show_hidden_releases := account != nil 55 62 56 - var dbCredits []*model.Credit 57 63 dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) 58 64 if err != nil { 59 - fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) 65 + fmt.Printf("WARN: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) 60 66 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 61 67 return 62 68 } ··· 74 80 } 75 81 76 82 w.Header().Add("Content-Type", "application/json") 77 - err = json.NewEncoder(w).Encode(artistJSON{ 83 + encoder := json.NewEncoder(w) 84 + encoder.SetIndent("", "\t") 85 + err = encoder.Encode(artistJSON{ 78 86 Artist: artist, 79 87 Credits: credits, 80 88 }) ··· 105 113 http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) 106 114 return 107 115 } 108 - fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err) 116 + fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err) 109 117 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 110 118 return 111 119 } ··· 118 126 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 119 127 err := json.NewDecoder(r.Body).Decode(&artist) 120 128 if err != nil { 121 - fmt.Printf("FATAL: Failed to update artist: %s\n", err) 129 + fmt.Printf("WARN: Failed to update artist: %s\n", err) 122 130 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 123 131 return 124 132 } ··· 153 161 http.NotFound(w, r) 154 162 return 155 163 } 156 - fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err) 164 + fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err) 157 165 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 158 166 } 159 167 }) ··· 167 175 http.NotFound(w, r) 168 176 return 169 177 } 170 - fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err) 178 + fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err) 171 179 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 172 180 } 173 181 })
+53 -22
api/release.go
··· 10 10 "strings" 11 11 "time" 12 12 13 - "arimelody-web/admin" 14 13 "arimelody-web/global" 15 14 "arimelody-web/controller" 16 15 "arimelody-web/model" ··· 19 18 func ServeRelease(release *model.Release) http.Handler { 20 19 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 20 // only allow authorised users to view hidden releases 22 - authorised := admin.GetSession(r) != nil 23 - if !authorised && !release.Visible { 24 - http.NotFound(w, r) 25 - return 21 + privileged := false 22 + if !release.Visible { 23 + account, err := controller.GetAccountByRequest(global.DB, r) 24 + if err != nil { 25 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 26 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 27 + return 28 + } 29 + if account != nil { 30 + // TODO: check privilege on release 31 + privileged = true 32 + } 33 + 34 + if !privileged { 35 + http.NotFound(w, r) 36 + return 37 + } 26 38 } 27 39 28 40 type ( ··· 53 65 Links: make(map[string]string), 54 66 } 55 67 56 - if authorised || release.IsReleased() { 68 + if release.IsReleased() || privileged { 57 69 // get credits 58 70 credits, err := controller.GetReleaseCredits(global.DB, release.ID) 59 71 if err != nil { 60 - fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err) 72 + fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err) 61 73 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 62 74 return 63 75 } 64 76 for _, credit := range credits { 65 77 artist, err := controller.GetArtist(global.DB, credit.Artist.ID) 66 78 if err != nil { 67 - fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err) 79 + fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err) 68 80 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 69 81 return 70 82 } ··· 79 91 // get tracks 80 92 tracks, err := controller.GetReleaseTracks(global.DB, release.ID) 81 93 if err != nil { 82 - fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err) 94 + fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err) 83 95 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 84 96 return 85 97 } ··· 94 106 // get links 95 107 links, err := controller.GetReleaseLinks(global.DB, release.ID) 96 108 if err != nil { 97 - fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err) 109 + fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err) 98 110 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 99 111 return 100 112 } ··· 104 116 } 105 117 106 118 w.Header().Add("Content-Type", "application/json") 107 - err := json.NewEncoder(w).Encode(response) 119 + encoder := json.NewEncoder(w) 120 + encoder.SetIndent("", "\t") 121 + err := encoder.Encode(response) 108 122 if err != nil { 109 123 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 110 124 return ··· 132 146 } 133 147 134 148 catalog := []Release{} 135 - authorised := admin.GetSession(r) != nil 149 + account, err := controller.GetAccountByRequest(global.DB, r) 150 + if err != nil { 151 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 152 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 153 + return 154 + } 136 155 for _, release := range releases { 137 - if !release.Visible && !authorised { 138 - continue 156 + if !release.Visible { 157 + privileged := false 158 + if account != nil { 159 + // TODO: check privilege on release 160 + privileged = true 161 + } 162 + if !privileged { 163 + continue 164 + } 139 165 } 166 + 140 167 artists := []string{} 141 168 for _, credit := range release.Credits { 142 169 if !credit.Primary { continue } ··· 155 182 } 156 183 157 184 w.Header().Add("Content-Type", "application/json") 158 - err = json.NewEncoder(w).Encode(catalog) 185 + encoder := json.NewEncoder(w) 186 + encoder.SetIndent("", "\t") 187 + err = encoder.Encode(catalog) 159 188 if err != nil { 160 189 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 161 190 return ··· 197 226 http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) 198 227 return 199 228 } 200 - fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err) 229 + fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err) 201 230 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 202 231 return 203 232 } 204 233 205 234 w.Header().Add("Content-Type", "application/json") 206 235 w.WriteHeader(http.StatusCreated) 207 - err = json.NewEncoder(w).Encode(release) 236 + encoder := json.NewEncoder(w) 237 + encoder.SetIndent("", "\t") 238 + err = encoder.Encode(release) 208 239 if err != nil { 209 240 fmt.Printf("WARN: Release %s created, but failed to send JSON response: %s\n", release.ID, err) 210 241 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 275 306 http.NotFound(w, r) 276 307 return 277 308 } 278 - fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err) 309 + fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err) 279 310 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 280 311 } 281 312 }) ··· 296 327 http.NotFound(w, r) 297 328 return 298 329 } 299 - fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err) 330 + fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err) 300 331 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 301 332 } 302 333 }) ··· 337 368 http.NotFound(w, r) 338 369 return 339 370 } 340 - fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) 371 + fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) 341 372 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 342 373 } 343 374 }) ··· 363 394 http.NotFound(w, r) 364 395 return 365 396 } 366 - fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) 397 + fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) 367 398 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 368 399 } 369 400 }) ··· 377 408 http.NotFound(w, r) 378 409 return 379 410 } 380 - fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err) 411 + fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err) 381 412 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 382 413 } 383 414 })
+14 -8
api/track.go
··· 28 28 var dbTracks = []*model.Track{} 29 29 dbTracks, err := controller.GetAllTracks(global.DB) 30 30 if err != nil { 31 - fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err) 31 + fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) 32 32 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 33 33 } 34 34 ··· 40 40 } 41 41 42 42 w.Header().Add("Content-Type", "application/json") 43 - err = json.NewEncoder(w).Encode(tracks) 43 + encoder := json.NewEncoder(w) 44 + encoder.SetIndent("", "\t") 45 + err = encoder.Encode(tracks) 44 46 if err != nil { 45 - fmt.Printf("FATAL: Failed to serve all tracks: %s\n", err) 47 + fmt.Printf("WARN: Failed to serve all tracks: %s\n", err) 46 48 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 47 49 } 48 50 }) ··· 52 54 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 55 dbReleases, err := controller.GetTrackReleases(global.DB, track.ID, false) 54 56 if err != nil { 55 - fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err) 57 + fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) 56 58 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 57 59 } 58 60 ··· 62 64 } 63 65 64 66 w.Header().Add("Content-Type", "application/json") 65 - err = json.NewEncoder(w).Encode(Track{ track, releases }) 67 + encoder := json.NewEncoder(w) 68 + encoder.SetIndent("", "\t") 69 + err = encoder.Encode(Track{ track, releases }) 66 70 if err != nil { 67 - fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err) 71 + fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err) 68 72 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 69 73 } 70 74 }) ··· 91 95 92 96 id, err := controller.CreateTrack(global.DB, &track) 93 97 if err != nil { 94 - fmt.Printf("FATAL: Failed to create track: %s\n", err) 98 + fmt.Printf("WARN: Failed to create track: %s\n", err) 95 99 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 96 100 return 97 101 } ··· 128 132 } 129 133 130 134 w.Header().Add("Content-Type", "application/json") 131 - err = json.NewEncoder(w).Encode(track) 135 + encoder := json.NewEncoder(w) 136 + encoder.SetIndent("", "\t") 137 + err = encoder.Encode(track) 132 138 if err != nil { 133 139 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 134 140 }
+2
api/uploads.go
··· 1 1 package api 2 2 3 3 import ( 4 + "arimelody-web/global" 4 5 "bufio" 5 6 "encoding/base64" 6 7 "errors" ··· 15 16 header := split[0] 16 17 imageData, err := base64.StdEncoding.DecodeString(split[1]) 17 18 ext, _ := strings.CutPrefix(header, "data:image/") 19 + directory = filepath.Join(global.Config.DataDirectory, directory) 18 20 19 21 switch ext { 20 22 case "png":
+9
bundle.sh
··· 1 + #!/bin/bash 2 + # simple script to pack up arimelody.me for production distribution 3 + 4 + if [ ! -f arimelody-web ]; then 5 + echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first." 6 + exit 1 7 + fi 8 + 9 + tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/
+124
controller/account.go
··· 1 + package controller 2 + 3 + import ( 4 + "arimelody-web/global" 5 + "arimelody-web/model" 6 + "errors" 7 + "fmt" 8 + "math/rand" 9 + "net/http" 10 + "strings" 11 + 12 + "github.com/jmoiron/sqlx" 13 + ) 14 + 15 + func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { 16 + var account = model.Account{} 17 + 18 + err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) 19 + if err != nil { 20 + return nil, err 21 + } 22 + 23 + return &account, nil 24 + } 25 + 26 + func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) { 27 + var account = model.Account{} 28 + 29 + err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email) 30 + if err != nil { 31 + return nil, err 32 + } 33 + 34 + return &account, nil 35 + } 36 + 37 + func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) { 38 + if token == "" { return nil, nil } 39 + 40 + account := model.Account{} 41 + 42 + err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token) 43 + if err != nil { 44 + if err.Error() == "sql: no rows in result set" { 45 + return nil, nil 46 + } 47 + return nil, err 48 + } 49 + 50 + return &account, nil 51 + } 52 + 53 + func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { 54 + tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 55 + 56 + if tokenStr == "" { 57 + cookie, err := r.Cookie(global.COOKIE_TOKEN) 58 + if err != nil { 59 + // not logged in 60 + return nil, nil 61 + } 62 + tokenStr = cookie.Value 63 + } 64 + 65 + token, err := GetToken(db, tokenStr) 66 + if err != nil { 67 + if strings.HasPrefix(err.Error(), "sql: no rows") { 68 + return nil, nil 69 + } 70 + return nil, errors.New(fmt.Sprintf("GetToken: %s", err.Error())) 71 + } 72 + 73 + // does user-agent match the token? 74 + if r.UserAgent() != token.UserAgent { 75 + // invalidate the token 76 + DeleteToken(db, tokenStr) 77 + fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent()) 78 + // TODO: log unauthorised activity to the user 79 + return nil, errors.New("User agent mismatch") 80 + } 81 + 82 + return GetAccountByToken(db, tokenStr) 83 + } 84 + 85 + func CreateAccount(db *sqlx.DB, account *model.Account) error { 86 + _, err := db.Exec( 87 + "INSERT INTO account (username, password, email, avatar_url) " + 88 + "VALUES ($1, $2, $3, $4)", 89 + account.Username, 90 + account.Password, 91 + account.Email, 92 + account.AvatarURL) 93 + 94 + return err 95 + } 96 + 97 + func UpdateAccount(db *sqlx.DB, account *model.Account) error { 98 + _, err := db.Exec( 99 + "UPDATE account " + 100 + "SET username=$2, password=$3, email=$4, avatar_url=$5) " + 101 + "WHERE id=$1", 102 + account.ID, 103 + account.Username, 104 + account.Password, 105 + account.Email, 106 + account.AvatarURL) 107 + 108 + return err 109 + } 110 + 111 + func DeleteAccount(db *sqlx.DB, accountID string) error { 112 + _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) 113 + return err 114 + } 115 + 116 + var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 117 + 118 + func GenerateInviteCode(length int) []byte { 119 + code := []byte{} 120 + for i := 0; i < length; i++ { 121 + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) 122 + } 123 + return code 124 + }
+13
controller/controller.go
··· 1 + package controller 2 + 3 + import "math/rand" 4 + 5 + func GenerateAlnumString(length int) []byte { 6 + const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 7 + res := []byte{} 8 + for i := 0; i < length; i++ { 9 + res = append(res, CHARS[rand.Intn(len(CHARS))]) 10 + } 11 + return res 12 + } 13 +
-1
controller/release.go
··· 223 223 return err 224 224 } 225 225 for _, link := range new_links { 226 - fmt.Printf("%s: %s\n", link.Name, link.URL) 227 226 _, err := tx.Exec( 228 227 "INSERT INTO musiclink "+ 229 228 "(release, name, url) "+
+61
controller/token.go
··· 1 + package controller 2 + 3 + import ( 4 + "time" 5 + 6 + "arimelody-web/model" 7 + 8 + "github.com/jmoiron/sqlx" 9 + ) 10 + 11 + const TOKEN_LEN = 32 12 + 13 + func CreateToken(db *sqlx.DB, accountID string, userAgent string) (*model.Token, error) { 14 + tokenString := GenerateAlnumString(TOKEN_LEN) 15 + 16 + token := model.Token{ 17 + Token: string(tokenString), 18 + AccountID: accountID, 19 + UserAgent: userAgent, 20 + CreatedAt: time.Now(), 21 + ExpiresAt: time.Now().Add(time.Hour * 24), 22 + } 23 + 24 + _, err := db.Exec("INSERT INTO token " + 25 + "(token, account, user_agent, created_at, expires_at) VALUES " + 26 + "($1, $2, $3, $4, $5)", 27 + token.Token, 28 + token.AccountID, 29 + token.UserAgent, 30 + token.CreatedAt, 31 + token.ExpiresAt, 32 + ) 33 + if err != nil { 34 + return nil, err 35 + } 36 + 37 + return &token, nil 38 + } 39 + 40 + func GetToken(db *sqlx.DB, token_str string) (*model.Token, error) { 41 + token := model.Token{} 42 + err := db.Get(&token, "SELECT * FROM token WHERE token=$1", token_str) 43 + return &token, err 44 + } 45 + 46 + func GetAllTokensForAccount(db *sqlx.DB, accountID string) ([]model.Token, error) { 47 + tokens := []model.Token{} 48 + err := db.Select(&tokens, "SELECT * FROM token WHERE account=$1 AND expires_at>current_timestamp", accountID) 49 + return tokens, err 50 + } 51 + 52 + func DeleteAllTokensForAccount(db *sqlx.DB, accountID string) error { 53 + _, err := db.Exec("DELETE FROM token WHERE account=$1", accountID) 54 + return err 55 + } 56 + 57 + func DeleteToken(db *sqlx.DB, token string) error { 58 + _, err := db.Exec("DELETE FROM token WHERE token=$1", token) 59 + return err 60 + } 61 +
+17 -4
discord/discord.go
··· 6 6 "fmt" 7 7 "net/http" 8 8 "net/url" 9 - "os" 10 9 "strings" 11 10 12 11 "arimelody-web/global" ··· 15 14 const API_ENDPOINT = "https://discord.com/api/v10" 16 15 17 16 var CREDENTIALS_PROVIDED = true 18 - var CLIENT_ID = os.Getenv("DISCORD_CLIENT") 19 - var CLIENT_SECRET = os.Getenv("DISCORD_SECRET") 20 - var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.HTTP_DOMAIN) 17 + var CLIENT_ID = func() string { 18 + id := global.Config.Discord.ClientID 19 + if id == "" { 20 + // fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided.\n") 21 + CREDENTIALS_PROVIDED = false 22 + } 23 + return id 24 + }() 25 + var CLIENT_SECRET = func() string { 26 + secret := global.Config.Discord.Secret 27 + if secret == "" { 28 + // fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided.\n") 29 + CREDENTIALS_PROVIDED = false 30 + } 31 + return secret 32 + }() 33 + var OAUTH_CALLBACK_URI = fmt.Sprintf("%s/admin/login", global.Config.BaseUrl) 21 34 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) 22 35 23 36 type (
+23
docker-compose.example.yml
··· 1 + services: 2 + web: 3 + image: docker.arimelody.me/arimelody.me:latest 4 + build: . 5 + ports: 6 + - 8080:8080 7 + volumes: 8 + - ./uploads:/app/uploads 9 + - ./config.toml:/app/config.toml 10 + environment: 11 + ARIMELODY_CONFIG: config.toml 12 + db: 13 + image: postgres:16.1-alpine3.18 14 + volumes: 15 + - arimelody-db:/var/lib/postgresql/data 16 + environment: 17 + POSTGRES_DB: # your database name here! 18 + POSTGRES_USER: # your database user here! 19 + POSTGRES_PASSWORD: # your database password here! 20 + 21 + volumes: 22 + arimelody-db: 23 + external: true
-22
docker-compose.yml
··· 1 - services: 2 - web: 3 - image: docker.arimelody.me/arimelody.me:latest 4 - build: . 5 - ports: 6 - - 8080:8080 7 - volumes: 8 - - ./uploads:/app/uploads 9 - environment: 10 - HTTP_DOMAIN: "https://arimelody.me" 11 - ARIMELODY_DB_HOST: db 12 - DISCORD_ADMIN: # your discord user ID. 13 - DISCORD_CLIENT: # your discord OAuth client ID. 14 - DISCORD_SECRET: # your discord OAuth secret. 15 - db: 16 - image: postgres:16.1-alpine3.18 17 - volumes: 18 - - ./db:/var/lib/postgresql/data 19 - environment: 20 - POSTGRES_DB: arimelody 21 - POSTGRES_USER: arimelody 22 - POSTGRES_PASSWORD: fuckingpassword
+121
global/config.go
··· 1 + package global 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "os" 7 + "strconv" 8 + "strings" 9 + 10 + "github.com/jmoiron/sqlx" 11 + "github.com/pelletier/go-toml/v2" 12 + ) 13 + 14 + type ( 15 + dbConfig struct { 16 + Host string `toml:"host"` 17 + Name string `toml:"name"` 18 + User string `toml:"user"` 19 + Pass string `toml:"pass"` 20 + } 21 + 22 + discordConfig struct { 23 + AdminID string `toml:"admin_id" comment:"NOTE: admin_id to be deprecated in favour of local accounts and SSO."` 24 + ClientID string `toml:"client_id"` 25 + Secret string `toml:"secret"` 26 + } 27 + 28 + config struct { 29 + BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` 30 + Port int64 `toml:"port"` 31 + DataDirectory string `toml:"data_dir"` 32 + DB dbConfig `toml:"db"` 33 + Discord discordConfig `toml:"discord"` 34 + } 35 + ) 36 + 37 + var Config = func() config { 38 + configFile := os.Getenv("ARIMELODY_CONFIG") 39 + if configFile == "" { 40 + configFile = "config.toml" 41 + } 42 + 43 + config := config{ 44 + BaseUrl: "https://arimelody.me", 45 + Port: 8080, 46 + } 47 + 48 + data, err := os.ReadFile(configFile) 49 + if err != nil { 50 + configOut, _ := toml.Marshal(&config) 51 + os.WriteFile(configFile, configOut, os.ModePerm) 52 + fmt.Printf( 53 + "A default config.toml has been created. " + 54 + "Please configure before running again!\n") 55 + os.Exit(0) 56 + } 57 + 58 + err = toml.Unmarshal([]byte(data), &config) 59 + if err != nil { 60 + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error()) 61 + os.Exit(1) 62 + } 63 + 64 + err = handleConfigOverrides(&config) 65 + if err != nil { 66 + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error()) 67 + os.Exit(1) 68 + } 69 + 70 + return config 71 + }() 72 + 73 + func handleConfigOverrides(config *config) error { 74 + var err error 75 + 76 + if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } 77 + if env, has := os.LookupEnv("ARIMELODY_PORT"); has { 78 + config.Port, err = strconv.ParseInt(env, 10, 0) 79 + if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) } 80 + } 81 + if env, has := os.LookupEnv("ARIMELODY_DATA_DIR"); has { config.DataDirectory = env } 82 + 83 + if env, has := os.LookupEnv("ARIMELODY_DB_HOST"); has { config.DB.Host = env } 84 + if env, has := os.LookupEnv("ARIMELODY_DB_NAME"); has { config.DB.Name = env } 85 + if env, has := os.LookupEnv("ARIMELODY_DB_USER"); has { config.DB.User = env } 86 + if env, has := os.LookupEnv("ARIMELODY_DB_PASS"); has { config.DB.Pass = env } 87 + 88 + if env, has := os.LookupEnv("ARIMELODY_DISCORD_ADMIN_ID"); has { config.Discord.AdminID = env } 89 + if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env } 90 + if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env } 91 + 92 + return nil 93 + } 94 + 95 + var Args = func() map[string]string { 96 + args := map[string]string{} 97 + 98 + index := 0 99 + for index < len(os.Args[1:]) { 100 + arg := os.Args[index + 1] 101 + if !strings.HasPrefix(arg, "-") { 102 + fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg) 103 + os.Exit(1) 104 + } 105 + 106 + if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") { 107 + args[arg[1:]] = "true" 108 + index += 1 109 + continue 110 + } 111 + 112 + val := os.Args[index + 2] 113 + args[arg[1:]] = val 114 + // fmt.Printf("%s: %s\n", arg[1:], val) 115 + index += 2 116 + } 117 + 118 + return args 119 + }() 120 + 121 + var DB *sqlx.DB
+3
global/const.go
··· 1 + package global 2 + 3 + const COOKIE_TOKEN string = "AM_TOKEN"
-45
global/data.go
··· 1 - package global 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - "strings" 7 - 8 - "github.com/jmoiron/sqlx" 9 - ) 10 - 11 - var Args = func() map[string]string { 12 - args := map[string]string{} 13 - 14 - index := 0 15 - for index < len(os.Args[1:]) { 16 - arg := os.Args[index + 1] 17 - if !strings.HasPrefix(arg, "-") { 18 - fmt.Printf("FATAL: Parameters must follow an argument (%s).\n", arg) 19 - os.Exit(1) 20 - } 21 - 22 - if index + 3 > len(os.Args) || strings.HasPrefix(os.Args[index + 2], "-") { 23 - args[arg[1:]] = "true" 24 - index += 1 25 - continue 26 - } 27 - 28 - val := os.Args[index + 2] 29 - args[arg[1:]] = val 30 - // fmt.Printf("%s: %s\n", arg[1:], val) 31 - index += 2 32 - } 33 - 34 - return args 35 - }() 36 - 37 - var HTTP_DOMAIN = func() string { 38 - domain := os.Getenv("HTTP_DOMAIN") 39 - if domain == "" { 40 - return "https://arimelody.me" 41 - } 42 - return domain 43 - }() 44 - 45 - var DB *sqlx.DB
+45 -7
global/funcs.go
··· 1 1 package global 2 2 3 3 import ( 4 - "fmt" 5 - "net/http" 6 - "strconv" 7 - "time" 4 + "fmt" 5 + "math/rand" 6 + "net/http" 7 + "strconv" 8 + "time" 8 9 9 - "arimelody-web/colour" 10 + "arimelody-web/colour" 10 11 ) 11 12 13 + var PoweredByStrings = []string{ 14 + "nerd rage", 15 + "estrogen", 16 + "your mother", 17 + "awesome powers beyond comprehension", 18 + "jared", 19 + "the weight of my sins", 20 + "the arc reactor", 21 + "AA batteries", 22 + "15 euro solar panel from ebay", 23 + "magnets, how do they work", 24 + "a fax machine", 25 + "dell optiplex", 26 + "a trans girl's nintendo wii", 27 + "BASS", 28 + "electricity, duh", 29 + "seven hamsters in a big wheel", 30 + "girls", 31 + "mzungu hosting", 32 + "golang", 33 + "the state of the world right now", 34 + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)", 35 + "the good folks at aperture science", 36 + "free2play CDs", 37 + "aridoodle", 38 + "the love of creating", 39 + "not for the sake of art; not for the sake of money; we like painting naked people", 40 + "30 billion dollars in VC funding", 41 + } 42 + 12 43 func DefaultHeaders(next http.Handler) http.Handler { 13 44 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 45 w.Header().Add("Server", "arimelody.me") 15 - w.Header().Add("Cache-Control", "max-age=2592000") 46 + w.Header().Add("Do-Not-Stab", "1") 47 + w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett") 48 + w.Header().Add("X-Hacker", "spare me please") 49 + w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;") 50 + w.Header().Add("X-Thinking-With", "Portals") 51 + w.Header().Add( 52 + "X-Powered-By", 53 + PoweredByStrings[rand.Intn(len(PoweredByStrings))], 54 + ) 16 55 next.ServeHTTP(w, r) 17 56 }) 18 57 } ··· 60 99 r.Header["User-Agent"][0]) 61 100 }) 62 101 } 63 -
+3
go.mod
··· 6 6 github.com/jmoiron/sqlx v1.4.0 7 7 github.com/lib/pq v1.10.9 8 8 ) 9 + 10 + require golang.org/x/crypto v0.27.0 // indirect 11 + require github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+4
go.sum
··· 8 8 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 9 9 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 10 10 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 11 + golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= 12 + golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= 13 + github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 14 + github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+216 -11
main.go
··· 13 13 "arimelody-web/api" 14 14 "arimelody-web/global" 15 15 "arimelody-web/view" 16 + "arimelody-web/controller" 16 17 "arimelody-web/templates" 17 18 18 19 "github.com/jmoiron/sqlx" 19 20 _ "github.com/lib/pq" 20 21 ) 21 22 22 - const DEFAULT_PORT int = 8080 23 + const DEFAULT_PORT int64 = 8080 23 24 24 25 func main() { 25 26 // initialise database connection 26 - var dbHost = os.Getenv("ARIMELODY_DB_HOST") 27 - if dbHost == "" { dbHost = "127.0.0.1" } 27 + if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } 28 + if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } 29 + if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env } 30 + if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env } 31 + if global.Config.DB.Host == "" { 32 + fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") 33 + os.Exit(1) 34 + } 35 + if global.Config.DB.Name == "" { 36 + fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") 37 + os.Exit(1) 38 + } 39 + if global.Config.DB.User == "" { 40 + fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") 41 + os.Exit(1) 42 + } 43 + if global.Config.DB.Pass == "" { 44 + fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") 45 + os.Exit(1) 46 + } 28 47 29 48 var err error 30 - global.DB, err = sqlx.Connect("postgres", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable") 49 + global.DB, err = sqlx.Connect( 50 + "postgres", 51 + fmt.Sprintf( 52 + "host=%s user=%s dbname=%s password='%s' sslmode=disable", 53 + global.Config.DB.Host, 54 + global.Config.DB.User, 55 + global.Config.DB.Name, 56 + global.Config.DB.Pass, 57 + ), 58 + ) 31 59 if err != nil { 32 - fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err) 60 + fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) 33 61 os.Exit(1) 34 62 } 35 63 global.DB.SetConnMaxLifetime(time.Minute * 3) ··· 37 65 global.DB.SetMaxIdleConns(10) 38 66 defer global.DB.Close() 39 67 68 + _, err = global.DB.Exec("DELETE FROM invite WHERE expires_at < CURRENT_TIMESTAMP") 69 + if err != nil { 70 + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) 71 + os.Exit(1) 72 + } 73 + 74 + accountsCount := 0 75 + global.DB.Get(&accountsCount, "SELECT count(*) FROM account") 76 + if accountsCount == 0 { 77 + code := controller.GenerateInviteCode(8) 78 + 79 + tx, err := global.DB.Begin() 80 + if err != nil { 81 + fmt.Fprintf(os.Stderr, "FATAL: Failed to begin transaction: %v\n", err) 82 + os.Exit(1) 83 + } 84 + _, err = tx.Exec("DELETE FROM invite") 85 + if err != nil { 86 + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) 87 + os.Exit(1) 88 + } 89 + _, err = tx.Exec("INSERT INTO invite (code,expires_at) VALUES ($1, $2)", code, time.Now().Add(60 * time.Minute)) 90 + if err != nil { 91 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) 92 + os.Exit(1) 93 + } 94 + err = tx.Commit() 95 + if err != nil { 96 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) 97 + os.Exit(1) 98 + } 99 + 100 + fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)") 101 + } 102 + 40 103 // start the web server! 41 104 mux := createServeMux() 42 - port := DEFAULT_PORT 43 - fmt.Printf("Now serving at http://127.0.0.1:%d\n", port) 44 - log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), global.HTTPLog(mux))) 105 + fmt.Printf("Now serving at http://127.0.0.1:%d\n", global.Config.Port) 106 + log.Fatal( 107 + http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), 108 + global.HTTPLog(global.DefaultHeaders(mux)), 109 + )) 110 + } 111 + 112 + func initDB(driverName string, dataSourceName string) (*sqlx.DB, error) { 113 + db, err := sqlx.Connect(driverName, dataSourceName) 114 + if err != nil { return nil, err } 115 + 116 + // ensure tables exist 117 + // account 118 + _, err = db.Exec( 119 + "CREATE TABLE IF NOT EXISTS account (" + 120 + "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " + 121 + "username text NOT NULL UNIQUE, " + 122 + "password text NOT NULL, " + 123 + "email text, " + 124 + "avatar_url text)", 125 + ) 126 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create account table: %s", err.Error())) } 127 + 128 + // privilege 129 + _, err = db.Exec( 130 + "CREATE TABLE IF NOT EXISTS privilege (" + 131 + "account uuid NOT NULL, " + 132 + "privilege text NOT NULL, " + 133 + "CONSTRAINT privilege_pk PRIMARY KEY (account, privilege), " + 134 + "CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", 135 + ) 136 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create privilege table: %s", err.Error())) } 137 + 138 + // totp 139 + _, err = db.Exec( 140 + "CREATE TABLE IF NOT EXISTS totp (" + 141 + "account uuid NOT NULL, " + 142 + "name text NOT NULL, " + 143 + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + 144 + "CONSTRAINT totp_pk PRIMARY KEY (account, name), " + 145 + "CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE)", 146 + ) 147 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } 148 + 149 + // invites 150 + _, err = db.Exec( 151 + "CREATE TABLE IF NOT EXISTS invite (" + 152 + "code text NOT NULL PRIMARY KEY, " + 153 + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + 154 + "expires_at TIMESTAMP NOT NULL)", 155 + ) 156 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create TOTP table: %s", err.Error())) } 157 + 158 + // account token 159 + _, err = db.Exec( 160 + "CREATE TABLE IF NOT EXISTS token (" + 161 + "token TEXT PRIMARY KEY," + 162 + "account UUID REFERENCES account(id) ON DELETE CASCADE NOT NULL," + 163 + "user_agent TEXT NOT NULL," + 164 + "created_at TIMESTAMP NOT NULL DEFAULT current_timestamp)", 165 + ) 166 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create token table: %s\n", err.Error())) } 167 + 168 + // artist 169 + _, err = db.Exec( 170 + "CREATE TABLE IF NOT EXISTS artist (" + 171 + "id character varying(64) PRIMARY KEY, " + 172 + "name text NOT NULL, " + 173 + "website text, " + 174 + "avatar text)", 175 + ) 176 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create artist table: %s", err.Error())) } 177 + 178 + // musicrelease 179 + _, err = db.Exec( 180 + "CREATE TABLE IF NOT EXISTS musicrelease (" + 181 + "id character varying(64) PRIMARY KEY, " + 182 + "visible bool DEFAULT false, " + 183 + "title text NOT NULL, " + 184 + "description text, " + 185 + "type text, " + 186 + "release_date TIMESTAMP NOT NULL, " + 187 + "artwork text, " + 188 + "buyname text, " + 189 + "buylink text, " + 190 + "copyright text, " + 191 + "copyrightURL text)", 192 + ) 193 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicrelease table: %s", err.Error())) } 194 + 195 + // musiclink 196 + _, err = db.Exec( 197 + "CREATE TABLE IF NOT EXISTS public.musiclink (" + 198 + "release character varying(64) NOT NULL, " + 199 + "name text NOT NULL, " + 200 + "url text NOT NULL, " + 201 + "CONSTRAINT musiclink_pk PRIMARY KEY (release, name), " + 202 + "CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE)", 203 + ) 204 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiclink table: %s", err.Error())) } 205 + 206 + // musiccredit 207 + _, err = db.Exec( 208 + "CREATE TABLE IF NOT EXISTS public.musiccredit (" + 209 + "release character varying(64) NOT NULL, " + 210 + "artist character varying(64) NOT NULL, " + 211 + "role text NOT NULL, " + 212 + "is_primary boolean DEFAULT false, " + 213 + "CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist), " + 214 + "CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + 215 + "CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE)", 216 + ) 217 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musiccredit table: %s", err.Error())) } 218 + 219 + // musictrack 220 + _, err = db.Exec( 221 + "CREATE TABLE IF NOT EXISTS public.musictrack (" + 222 + "id uuid DEFAULT gen_random_uuid() PRIMARY KEY, " + 223 + "title text NOT NULL, " + 224 + "description text, " + 225 + "lyrics text, " + 226 + "preview_url text)", 227 + ) 228 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musictrack table: %s", err.Error())) } 229 + 230 + // musicreleasetrack 231 + _, err = db.Exec( 232 + "CREATE TABLE IF NOT EXISTS public.musicreleasetrack (" + 233 + "release character varying(64) NOT NULL, " + 234 + "track uuid NOT NULL, " + 235 + "number integer NOT NULL, " + 236 + "CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track), " + 237 + "CONSTRAINT musicreleasetrack_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE, " + 238 + "CONSTRAINT musicreleasetrack_artist_fk FOREIGN KEY (track) REFERENCES track(id) ON DELETE CASCADE)", 239 + ) 240 + if err != nil { return nil, errors.New(fmt.Sprintf("Failed to create musicreleasetrack table: %s", err.Error())) } 241 + 242 + // TODO: automatic database migration 243 + 244 + return db, nil 45 245 } 46 246 47 247 func createServeMux() *http.ServeMux { 48 248 mux := http.NewServeMux() 49 - 249 + 50 250 mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler())) 51 251 mux.Handle("/api/", http.StripPrefix("/api", api.Handler())) 52 252 mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler())) 53 - mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler("uploads"))) 253 + mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads")))) 54 254 mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 255 + if r.Method == http.MethodHead { 256 + w.WriteHeader(http.StatusOK) 257 + return 258 + } 259 + 55 260 if r.URL.Path == "/" || r.URL.Path == "/index.html" { 56 261 err := templates.Pages["index"].Execute(w, nil) 57 262 if err != nil { ··· 61 266 } 62 267 staticHandler("public").ServeHTTP(w, r) 63 268 })) 64 - 269 + 65 270 return mux 66 271 } 67 272
+43
model/account.go
··· 1 + package model 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type ( 8 + Account struct { 9 + ID string `json:"id" db:"id"` 10 + Username string `json:"username" db:"username"` 11 + Password []byte `json:"password" db:"password"` 12 + Email string `json:"email" db:"email"` 13 + AvatarURL string `json:"avatar_url" db:"avatar_url"` 14 + Privileges []AccountPrivilege `json:"privileges"` 15 + } 16 + 17 + AccountPrivilege string 18 + 19 + Invite struct { 20 + Code string `db:"code"` 21 + CreatedByID string `db:"created_by"` 22 + CreatedAt time.Time `db:"created_at"` 23 + ExpiresAt time.Time `db:"expires_at"` 24 + } 25 + ) 26 + 27 + const ( 28 + Root AccountPrivilege = "root" // grants all permissions. very dangerous to grant! 29 + 30 + // unused for now 31 + CreateInvites AccountPrivilege = "create_invites" 32 + ReadAccounts AccountPrivilege = "read_accounts" 33 + EditAccounts AccountPrivilege = "edit_accounts" 34 + 35 + ReadReleases AccountPrivilege = "read_releases" 36 + EditReleases AccountPrivilege = "edit_releases" 37 + 38 + ReadTracks AccountPrivilege = "read_tracks" 39 + EditTracks AccountPrivilege = "edit_tracks" 40 + 41 + ReadArtists AccountPrivilege = "read_artists" 42 + EditArtists AccountPrivilege = "edit_artists" 43 + )
+11
model/token.go
··· 1 + package model 2 + 3 + import "time" 4 + 5 + type Token struct { 6 + Token string `json:"token" db:"token"` 7 + AccountID string `json:"-" db:"account"` 8 + UserAgent string `json:"user_agent" db:"user_agent"` 9 + CreatedAt time.Time `json:"created_at" db:"created_at"` 10 + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` 11 + }
public/img/buttons/aikoyori.gif

This is a binary file and will not be displayed.

public/img/buttons/ioletsgo.gif

This is a binary file and will not be displayed.

public/img/buttons/ipg.png

This is a binary file and will not be displayed.

public/img/buttons/isabelroses.gif

This is a binary file and will not be displayed.

public/img/buttons/itzzen.png

This is a binary file and will not be displayed.

public/img/buttons/notnite.png

This is a binary file and will not be displayed.

public/img/buttons/retr0id_now.gif

This is a binary file and will not be displayed.

public/img/buttons/stardust.png

This is a binary file and will not be displayed.

public/img/buttons/xenia.png

This is a binary file and will not be displayed.

+1 -1
public/style/index.css
··· 107 107 } 108 108 109 109 ul.links li a { 110 - padding: .2em .5em; 110 + padding: .4em .5em; 111 111 border: 1px solid var(--links); 112 112 color: var(--links); 113 113 border-radius: 2px;
+77 -17
schema.sql
··· 1 + CREATE SCHEMA arimelody AUTHORIZATION arimelody; 2 + 3 + -- 4 + -- Acounts 5 + -- 6 + CREATE TABLE arimelody.account ( 7 + id uuid DEFAULT gen_random_uuid(), 8 + username text NOT NULL UNIQUE, 9 + password text NOT NULL, 10 + email text, 11 + avatar_url text 12 + ); 13 + ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); 14 + 15 + -- 16 + -- Privilege 17 + -- 18 + CREATE TABLE arimelody.privilege ( 19 + account uuid NOT NULL, 20 + privilege text NOT NULL 21 + ); 22 + ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); 23 + 24 + -- 25 + -- TOTP 26 + -- 27 + CREATE TABLE arimelody.totp ( 28 + account uuid NOT NULL, 29 + name text NOT NULL, 30 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 31 + ); 32 + ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); 33 + 34 + -- 35 + -- Invites 36 + -- 37 + CREATE TABLE arimelody.invite ( 38 + code text NOT NULL, 39 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 40 + expires_at TIMESTAMP NOT NULL 41 + ); 42 + ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); 43 + 44 + -- 45 + -- Tokens 46 + -- 47 + CREATE TABLE arimelody.token ( 48 + token TEXT, 49 + account UUID NOT NULL, 50 + user_agent TEXT NOT NULL, 51 + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, 52 + expires_at TIMESTAMP DEFAULT NULL 53 + ); 54 + ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); 55 + 56 + 1 57 -- 2 58 -- Artists (should be applicable to all art) 3 59 -- 4 - CREATE TABLE public.artist ( 60 + CREATE TABLE arimelody.artist ( 5 61 id character varying(64), 6 62 name text NOT NULL, 7 63 website text, 8 64 avatar text 9 65 ); 10 - ALTER TABLE public.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); 66 + ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); 11 67 12 68 -- 13 69 -- Music releases 14 70 -- 15 - CREATE TABLE public.musicrelease ( 71 + CREATE TABLE arimelody.musicrelease ( 16 72 id character varying(64) NOT NULL, 17 73 visible bool DEFAULT false, 18 74 title text NOT NULL, ··· 25 81 copyright text, 26 82 copyrightURL text 27 83 ); 28 - ALTER TABLE public.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); 84 + ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); 29 85 30 86 -- 31 87 -- Music links (external platform links under a release) 32 88 -- 33 - CREATE TABLE public.musiclink ( 89 + CREATE TABLE arimelody.musiclink ( 34 90 release character varying(64) NOT NULL, 35 91 name text NOT NULL, 36 92 url text NOT NULL 37 93 ); 38 - ALTER TABLE public.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); 94 + ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); 39 95 40 96 -- 41 97 -- Music credits (artist credits under a release) 42 98 -- 43 - CREATE TABLE public.musiccredit ( 99 + CREATE TABLE arimelody.musiccredit ( 44 100 release character varying(64) NOT NULL, 45 101 artist character varying(64) NOT NULL, 46 102 role text NOT NULL, 47 103 is_primary boolean DEFAULT false 48 104 ); 49 - ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); 105 + ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); 50 106 51 107 -- 52 108 -- Music tracks (tracks under a release) 53 109 -- 54 - CREATE TABLE public.musictrack ( 110 + CREATE TABLE arimelody.musictrack ( 55 111 id uuid DEFAULT gen_random_uuid(), 56 112 title text NOT NULL, 57 113 description text, 58 114 lyrics text, 59 115 preview_url text 60 116 ); 61 - ALTER TABLE public.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); 117 + ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); 62 118 63 119 -- 64 120 -- Music release/track pairs 65 121 -- 66 - CREATE TABLE public.musicreleasetrack ( 122 + CREATE TABLE arimelody.musicreleasetrack ( 67 123 release character varying(64) NOT NULL, 68 124 track uuid NOT NULL, 69 125 number integer NOT NULL 70 126 ); 71 - ALTER TABLE public.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track); 127 + ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track); 72 128 73 129 -- 74 130 -- Foreign keys 75 131 -- 76 - ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES public.artist(id) ON DELETE CASCADE ON UPDATE CASCADE; 77 - ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON DELETE CASCADE; 78 - ALTER TABLE public.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE; 79 - ALTER TABLE public.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON DELETE CASCADE; 80 - ALTER TABLE public.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES public.musictrack(id) ON DELETE CASCADE; 132 + ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 133 + ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 134 + ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 135 + 136 + ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; 137 + ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; 138 + ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE; 139 + ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; 140 + ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(id) ON DELETE CASCADE;
+19 -6
view/music.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net/http" 6 + "os" 6 7 7 - "arimelody-web/admin" 8 8 "arimelody-web/controller" 9 9 "arimelody-web/global" 10 10 "arimelody-web/model" ··· 59 59 func ServeGateway(release *model.Release) http.Handler { 60 60 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 61 // only allow authorised users to view hidden releases 62 - authorised := admin.GetSession(r) != nil 63 - if !authorised && !release.Visible { 64 - http.NotFound(w, r) 65 - return 62 + privileged := false 63 + if !release.Visible { 64 + account, err := controller.GetAccountByRequest(global.DB, r) 65 + if err != nil { 66 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 67 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 68 + return 69 + } 70 + if account != nil { 71 + // TODO: check privilege on release 72 + privileged = true 73 + } 74 + 75 + if !privileged { 76 + http.NotFound(w, r) 77 + return 78 + } 66 79 } 67 80 68 81 response := *release 69 82 70 - if authorised || release.IsReleased() { 83 + if release.IsReleased() || privileged { 71 84 response.Tracks = release.Tracks 72 85 response.Credits = release.Credits 73 86 response.Links = release.Links
+29 -3
views/index.html
··· 129 129 OpenTerminal 130 130 </a> 131 131 </li> 132 + <li> 133 + <a href="https://silver.bliss.town/" target="_blank"> 134 + Silver.js 135 + </a> 136 + </li> 132 137 </ul> 133 138 134 139 <hr> 135 140 136 141 <h2 class="typeout"> 137 - ## cool people 142 + ## cool critters 138 143 </h2> 139 144 140 145 <div id="web-buttons"> ··· 153 158 <a href="https://elke.cafe" target="_blank"> 154 159 <img src="/img/buttons/elke.gif" alt="elke web button" width="88" height="31"> 155 160 </a> 156 - <a href="https://itzzen.net" target="_blank"> 157 - <img src="/img/buttons/itzzen.png" alt="itzzen web button" width="88" height="31"> 161 + <a href="https://invoxiplaygames.uk/" target="_blank"> 162 + <img src="/img/buttons/ipg.png" alt="InvoxiPlayGames web button" width="88" height="31"> 163 + </a> 164 + <a href="https://ioletsgo.gay" target="_blank"> 165 + <img src="/img/buttons/ioletsgo.gif" alt="ioletsgo web button" width="88" height="31"> 166 + </a> 167 + <a href="https://notnite.com/" target="_blank"> 168 + <img src="/img/buttons/notnite.png" alt="notnite web button" width="88" height="31"> 169 + </a> 170 + <a href="https://www.da.vidbuchanan.co.uk/" target="_blank"> 171 + <img src="/img/buttons/retr0id_now.gif" alt="retr0id web button" width="88" height="31"> 172 + </a> 173 + <a href="https://aikoyori.xyz" target="_blank"> 174 + <img src="/img/buttons/aikoyori.gif" alt="aikoyori web button" width="88" height="31"> 175 + </a> 176 + <a href="https://xenia.blahaj.land/" target="_blank"> 177 + <img src="/img/buttons/xenia.png" alt="xenia web button" width="88" height="31"> 178 + </a> 179 + <a href="https://stardust.elysium.gay/" target="_blank"> 180 + <img src="/img/buttons/stardust.png" alt="stardust web button" width="88" height="31"> 181 + </a> 182 + <a href="https://isabelroses.com/" target="_blank"> 183 + <img src="/img/buttons/isabelroses.gif" alt="isabel roses web button" width="88" height="31"> 158 184 </a> 159 185 160 186 <hr>