home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

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

schema migration and account fixes

very close to rolling this out! just need to address some security concerns first

+639 -390
+13 -3
README.md
··· 21 21 ## running 22 22 23 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`). 24 + configure as needed. a valid DB connection is required to run this website. 25 + if no admin users exist, an invite code will be provided. invite codes are 26 + the only way to create admin accounts at this time. 27 27 28 28 the configuration may be overridden using environment variables in the format 29 29 `ARIMELODY_<SECTION_NAME>_<KEY_NAME>`. for example, `db.host` in the config may ··· 31 31 32 32 the location of the configuration file can also be overridden with 33 33 `ARIMELODY_CONFIG`. 34 + 35 + ## command arguments 36 + 37 + by default, `arimelody-web` will spin up a web server as usual. instead, 38 + arguments may be supplied to run administrative actions. the web server doesn't 39 + need to be up for this, making this ideal for some offline maintenance. 40 + 41 + - `createInvite`: Creates an invite code to register new accounts. 42 + - `purgeInvites`: Deletes all available invite codes. 43 + - `deleteAccount <username>`: Deletes an account with a given `username`. 34 44 35 45 ## database 36 46
-38
admin/admin.go
··· 1 - package admin 2 - 3 - import ( 4 - "fmt" 5 - "time" 6 - 7 - "arimelody-web/controller" 8 - "arimelody-web/global" 9 - "arimelody-web/model" 10 - ) 11 - 12 - type ( 13 - Session struct { 14 - Token string 15 - Account *model.Account 16 - Expires time.Time 17 - } 18 - ) 19 - 20 - const TOKEN_LENGTH = 64 21 - 22 - var ADMIN_BYPASS = func() bool { 23 - if global.Args["adminBypass"] == "true" { 24 - fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)") 25 - return true 26 - } 27 - return false 28 - }() 29 - 30 - var sessions []*Session 31 - 32 - func createSession(account *model.Account, expires time.Time) Session { 33 - return Session{ 34 - Token: string(controller.GenerateAlnumString(TOKEN_LENGTH)), 35 - Account: account, 36 - Expires: expires, 37 - } 38 - }
+152 -28
admin/http.go
··· 26 26 mux := http.NewServeMux() 27 27 28 28 mux.Handle("/login", LoginHandler()) 29 - mux.Handle("/create-account", createAccountHandler()) 29 + mux.Handle("/register", createAccountHandler()) 30 30 mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler())) 31 + // TODO: /admin/account 31 32 mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) 32 33 mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease()))) 33 34 mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist()))) ··· 96 97 account, err := controller.GetAccountByRequest(db, r) 97 98 if err != nil { 98 99 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 99 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 100 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 100 101 return 101 102 } 102 103 if account == nil { ··· 117 118 account, err := controller.GetAccountByRequest(global.DB, r) 118 119 if err != nil { 119 120 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 120 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 121 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 121 122 return 122 123 } 123 124 if account != nil { ··· 141 142 142 143 err := r.ParseForm() 143 144 if err != nil { 144 - fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err) 145 145 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 146 146 return 147 147 } ··· 151 151 Password string `json:"password"` 152 152 TOTP string `json:"totp"` 153 153 } 154 - data := LoginRequest{ 154 + credentials := LoginRequest{ 155 155 Username: r.Form.Get("username"), 156 156 Password: r.Form.Get("password"), 157 157 TOTP: r.Form.Get("totp"), 158 158 } 159 159 160 - account, err := controller.GetAccount(global.DB, data.Username) 160 + account, err := controller.GetAccount(global.DB, credentials.Username) 161 161 if err != nil { 162 - http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) 162 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 163 + return 164 + } 165 + if account == nil { 166 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 163 167 return 164 168 } 165 169 166 - err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password)) 170 + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) 167 171 if err != nil { 168 - http.Error(w, "No account exists with this username and password.", http.StatusBadRequest) 172 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 169 173 return 170 174 } 171 175 ··· 174 178 // login success! 175 179 token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) 176 180 if err != nil { 177 - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %s\n", err.Error()) 181 + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) 178 182 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 179 183 return 180 184 } ··· 209 213 return 210 214 } 211 215 212 - token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 213 - 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 220 - } 221 - if cookie != nil { 222 - token_str = cookie.Value 223 - } 224 - } 216 + tokenStr := controller.GetTokenFromRequest(global.DB, r) 225 217 226 - if len(token_str) > 0 { 227 - err := controller.DeleteToken(global.DB, token_str) 218 + if len(tokenStr) > 0 { 219 + err := controller.DeleteToken(global.DB, tokenStr) 228 220 if err != nil { 229 - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %s\n", err.Error()) 221 + fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) 230 222 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 231 223 return 232 224 } ··· 248 240 249 241 func createAccountHandler() http.Handler { 250 242 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 251 - err := pages["create-account"].Execute(w, TemplateData{}) 243 + checkAccount, err := controller.GetAccountByRequest(global.DB, r) 252 244 if err != nil { 253 - fmt.Printf("Error rendering create account page: %s\n", err) 245 + fmt.Printf("WARN: Failed to fetch account: %s\n", err) 246 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 247 + return 248 + } 249 + if checkAccount != nil { 250 + // user is already logged in 251 + http.Redirect(w, r, "/admin", http.StatusFound) 252 + return 253 + } 254 + 255 + type CreateAccountResponse struct { 256 + Account *model.Account 257 + Message string 258 + } 259 + 260 + render := func(data CreateAccountResponse) { 261 + err := pages["create-account"].Execute(w, data) 262 + if err != nil { 263 + fmt.Printf("WARN: Error rendering create account page: %s\n", err) 264 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 265 + } 266 + } 267 + 268 + if r.Method == http.MethodGet { 269 + render(CreateAccountResponse{}) 270 + return 271 + } 272 + 273 + if r.Method != http.MethodPost { 274 + http.NotFound(w, r) 275 + return 276 + } 277 + 278 + err = r.ParseForm() 279 + if err != nil { 280 + render(CreateAccountResponse{ 281 + Message: "Malformed data.", 282 + }) 283 + return 284 + } 285 + 286 + type RegisterRequest struct { 287 + Username string `json:"username"` 288 + Email string `json:"email"` 289 + Password string `json:"password"` 290 + Invite string `json:"invite"` 291 + } 292 + credentials := RegisterRequest{ 293 + Username: r.Form.Get("username"), 294 + Email: r.Form.Get("email"), 295 + Password: r.Form.Get("password"), 296 + Invite: r.Form.Get("invite"), 297 + } 298 + 299 + // make sure code exists in DB 300 + invite, err := controller.GetInvite(global.DB, credentials.Invite) 301 + if err != nil { 302 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) 303 + render(CreateAccountResponse{ 304 + Message: "Something went wrong. Please try again.", 305 + }) 306 + return 307 + } 308 + if invite == nil || time.Now().After(invite.ExpiresAt) { 309 + if invite != nil { 310 + err := controller.DeleteInvite(global.DB, invite.Code) 311 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 312 + } 313 + render(CreateAccountResponse{ 314 + Message: "Invalid invite code.", 315 + }) 316 + return 317 + } 318 + 319 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) 320 + if err != nil { 321 + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) 322 + render(CreateAccountResponse{ 323 + Message: "Something went wrong. Please try again.", 324 + }) 325 + return 326 + } 327 + 328 + account := model.Account{ 329 + Username: credentials.Username, 330 + Password: string(hashedPassword), 331 + Email: credentials.Email, 332 + AvatarURL: "/img/default-avatar.png", 333 + } 334 + err = controller.CreateAccount(global.DB, &account) 335 + if err != nil { 336 + if strings.HasPrefix(err.Error(), "pq: duplicate key") { 337 + render(CreateAccountResponse{ 338 + Message: "An account with that username already exists.", 339 + }) 340 + return 341 + } 342 + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) 343 + render(CreateAccountResponse{ 344 + Message: "Something went wrong. Please try again.", 345 + }) 346 + return 347 + } 348 + 349 + err = controller.DeleteInvite(global.DB, invite.Code) 350 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 351 + 352 + // registration success! 353 + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) 354 + if err != nil { 355 + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) 356 + // gracefully redirect user to login page 357 + http.Redirect(w, r, "/admin/login", http.StatusFound) 358 + return 359 + } 360 + 361 + cookie := http.Cookie{} 362 + cookie.Name = global.COOKIE_TOKEN 363 + cookie.Value = token.Token 364 + cookie.Expires = token.ExpiresAt 365 + if strings.HasPrefix(global.Config.BaseUrl, "https") { 366 + cookie.Secure = true 367 + } 368 + cookie.HttpOnly = true 369 + cookie.Path = "/" 370 + http.SetCookie(w, &cookie) 371 + 372 + err = pages["login"].Execute(w, TemplateData{ 373 + Account: &account, 374 + Token: token.Token, 375 + }) 376 + if err != nil { 377 + fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) 254 378 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 255 379 return 256 380 }
+8 -13
admin/views/create-account.html
··· 65 65 background: #d0d0d0; 66 66 border-color: #808080; 67 67 } 68 + 69 + #error { 70 + background: #ffa9b8; 71 + border: 1px solid #dc5959; 72 + padding: 1em; 73 + border-radius: 4px; 74 + } 68 75 </style> 69 76 {{end}} 70 77 71 78 {{define "content"}} 72 79 <main> 73 - {{if .Success}} 74 - 75 - <meta http-equiv="refresh" content="5;url=/admin/" /> 76 - <p> 77 - {{.Message}} 78 - You should be redirected to <a href="/admin">/admin</a> in 5 seconds. 79 - </p> 80 - 81 - {{else}} 82 - 83 80 {{if .Message}} 84 81 <p id="error">{{.Message}}</p> 85 82 {{end}} 86 83 87 - <form action="/admin/create-account" method="POST" id="create-account"> 84 + <form action="/admin/register" method="POST" id="create-account"> 88 85 <div> 89 86 <label for="username">Username</label> 90 87 <input type="text" name="username" value=""> ··· 101 98 102 99 <button type="submit" class="new">Create Account</button> 103 100 </form> 104 - 105 - {{end}} 106 101 </main> 107 102 {{end}}
+4
admin/views/layout.html
··· 28 28 <div class="nav-item"> 29 29 <a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a> 30 30 </div> 31 + {{else}} 32 + <div class="nav-item"> 33 + <a href="/admin/register" id="register">create account</a> 34 + </div> 31 35 {{end}} 32 36 </nav> 33 37 </header>
+5 -1
admin/views/login.html
··· 43 43 font-family: inherit; 44 44 color: inherit; 45 45 } 46 + input[disabled] { 47 + opacity: .5; 48 + cursor: not-allowed; 49 + } 46 50 47 51 button { 48 52 padding: .5em .8em; ··· 89 93 <input type="password" name="password" value=""> 90 94 91 95 <label for="totp">TOTP</label> 92 - <input type="text" name="totp" value=""> 96 + <input type="text" name="totp" value="" disabled> 93 97 </div> 94 98 95 99 <button type="submit" class="save">Login</button>
+58 -31
api/account.go
··· 35 35 36 36 account, err := controller.GetAccount(global.DB, credentials.Username) 37 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()) 38 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) 43 39 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 44 40 return 45 41 } 42 + if account == nil { 43 + http.Error(w, "Invalid username or password", http.StatusBadRequest) 44 + return 45 + } 46 46 47 - err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) 47 + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) 48 48 if err != nil { 49 49 http.Error(w, "Invalid username or password", http.StatusBadRequest) 50 50 return 51 51 } 52 52 53 - // TODO: sessions and tokens 53 + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) 54 + type LoginResponse struct { 55 + Token string `json:"token"` 56 + ExpiresAt time.Time `json:"expires_at"` 57 + } 54 58 55 - w.WriteHeader(http.StatusOK) 56 - w.Write([]byte("Logged in successfully. TODO: Session tokens\n")) 59 + err = json.NewEncoder(w).Encode(LoginResponse{ 60 + Token: token.Token, 61 + ExpiresAt: token.ExpiresAt, 62 + }) 63 + if err != nil { 64 + fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err) 65 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 66 + } 57 67 }) 58 68 } 59 69 ··· 68 78 Username string `json:"username"` 69 79 Email string `json:"email"` 70 80 Password string `json:"password"` 71 - Code string `json:"code"` 81 + Invite string `json:"invite"` 72 82 } 73 83 74 84 credentials := RegisterRequest{} ··· 79 89 } 80 90 81 91 // make sure code exists in DB 82 - invite := model.Invite{} 83 - err = global.DB.Get(&invite, "SELECT * FROM invite WHERE code=$1", credentials.Code) 92 + invite, err := controller.GetInvite(global.DB, credentials.Invite) 84 93 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()) 94 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) 90 95 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 91 96 return 92 97 } 98 + if invite == nil { 99 + http.Error(w, "Invalid invite code", http.StatusBadRequest) 100 + return 101 + } 93 102 94 103 if time.Now().After(invite.ExpiresAt) { 104 + err := controller.DeleteInvite(global.DB, invite.Code) 105 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 95 106 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 107 return 99 108 } 100 109 101 110 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) 102 111 if err != nil { 103 - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %s\n", err.Error()) 112 + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) 104 113 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 105 114 return 106 115 } 107 116 108 117 account := model.Account{ 109 118 Username: credentials.Username, 110 - Password: hashedPassword, 119 + Password: string(hashedPassword), 111 120 Email: credentials.Email, 112 121 AvatarURL: "/img/default-avatar.png", 113 122 } 114 123 err = controller.CreateAccount(global.DB, &account) 115 124 if err != nil { 116 - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %s\n", err.Error()) 125 + if strings.HasPrefix(err.Error(), "pq: duplicate key") { 126 + http.Error(w, "An account with that username already exists", http.StatusBadRequest) 127 + return 128 + } 129 + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) 117 130 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 118 131 return 119 132 } 120 133 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()) } 134 + err = controller.DeleteInvite(global.DB, invite.Code) 135 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 123 136 124 - w.WriteHeader(http.StatusCreated) 125 - w.Write([]byte("Account created successfully\n")) 137 + token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent()) 138 + type LoginResponse struct { 139 + Token string `json:"token"` 140 + ExpiresAt time.Time `json:"expires_at"` 141 + } 142 + 143 + err = json.NewEncoder(w).Encode(LoginResponse{ 144 + Token: token.Token, 145 + ExpiresAt: token.ExpiresAt, 146 + }) 147 + if err != nil { 148 + fmt.Fprintf(os.Stderr, "WARN: Failed to return session token: %v\n", err) 149 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 150 + } 126 151 }) 127 152 } 128 153 ··· 151 176 http.Error(w, "Invalid username or password", http.StatusBadRequest) 152 177 return 153 178 } 154 - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %s\n", err.Error()) 179 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve account: %v\n", err) 155 180 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 156 181 return 157 182 } 158 183 159 - err = bcrypt.CompareHashAndPassword(account.Password, []byte(credentials.Password)) 184 + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) 160 185 if err != nil { 161 - http.Error(w, "Invalid username or password", http.StatusBadRequest) 186 + http.Error(w, "Invalid password", http.StatusBadRequest) 162 187 return 163 188 } 164 189 165 - err = controller.DeleteAccount(global.DB, account.ID) 190 + // TODO: check TOTP 191 + 192 + err = controller.DeleteAccount(global.DB, account.Username) 166 193 if err != nil { 167 - fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %s\n", err.Error()) 194 + fmt.Fprintf(os.Stderr, "WARN: Failed to delete account: %v\n", err) 168 195 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 169 196 return 170 197 }
+2 -2
api/artist.go
··· 54 54 55 55 account, err := controller.GetAccountByRequest(global.DB, r) 56 56 if err != nil { 57 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 57 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 58 58 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 59 59 return 60 60 } ··· 62 62 63 63 dbCredits, err := controller.GetArtistCredits(global.DB, artist.ID, show_hidden_releases) 64 64 if err != nil { 65 - fmt.Printf("WARN: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) 65 + fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) 66 66 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 67 67 return 68 68 }
+2 -2
api/release.go
··· 22 22 if !release.Visible { 23 23 account, err := controller.GetAccountByRequest(global.DB, r) 24 24 if err != nil { 25 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 25 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 26 26 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 27 27 return 28 28 } ··· 148 148 catalog := []Release{} 149 149 account, err := controller.GetAccountByRequest(global.DB, r) 150 150 if err != nil { 151 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 151 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 152 152 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 153 153 return 154 154 }
+42 -39
controller/account.go
··· 5 5 "arimelody-web/model" 6 6 "errors" 7 7 "fmt" 8 - "math/rand" 9 8 "net/http" 10 9 "strings" 11 10 ··· 17 16 18 17 err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) 19 18 if err != nil { 19 + if strings.Contains(err.Error(), "no rows") { 20 + return nil, nil 21 + } 20 22 return nil, err 21 23 } 22 24 ··· 28 30 29 31 err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email) 30 32 if err != nil { 33 + if strings.Contains(err.Error(), "no rows") { 34 + return nil, nil 35 + } 31 36 return nil, err 32 37 } 33 38 ··· 41 46 42 47 err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token) 43 48 if err != nil { 44 - if err.Error() == "sql: no rows in result set" { 49 + if strings.Contains(err.Error(), "no rows") { 45 50 return nil, nil 46 51 } 47 52 return nil, err ··· 50 55 return &account, nil 51 56 } 52 57 53 - func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { 58 + func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string { 54 59 tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 60 + if len(tokenStr) > 0 { 61 + return tokenStr 62 + } 55 63 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 64 + cookie, err := r.Cookie(global.COOKIE_TOKEN) 65 + if err != nil { 66 + return "" 63 67 } 68 + return cookie.Value 69 + } 70 + 71 + func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { 72 + tokenStr := GetTokenFromRequest(db, r) 64 73 65 74 token, err := GetToken(db, tokenStr) 66 75 if err != nil { 67 - if strings.HasPrefix(err.Error(), "sql: no rows") { 76 + if strings.Contains(err.Error(), "no rows") { 68 77 return nil, nil 69 78 } 70 - return nil, errors.New(fmt.Sprintf("GetToken: %s", err.Error())) 79 + return nil, errors.New("GetToken: " + err.Error()) 71 80 } 72 81 73 82 // does user-agent match the token? ··· 83 92 } 84 93 85 94 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) 95 + err := db.Get( 96 + &account.ID, 97 + "INSERT INTO account (username, password, email, avatar_url) " + 98 + "VALUES ($1, $2, $3, $4) " + 99 + "RETURNING id", 100 + account.Username, 101 + account.Password, 102 + account.Email, 103 + account.AvatarURL, 104 + ) 93 105 94 106 return err 95 107 } 96 108 97 109 func UpdateAccount(db *sqlx.DB, account *model.Account) error { 98 110 _, 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) 111 + "UPDATE account " + 112 + "SET username=$2, password=$3, email=$4, avatar_url=$5) " + 113 + "WHERE id=$1", 114 + account.ID, 115 + account.Username, 116 + account.Password, 117 + account.Email, 118 + account.AvatarURL, 119 + ) 107 120 108 121 return err 109 122 } 110 123 111 - func DeleteAccount(db *sqlx.DB, accountID string) error { 112 - _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) 124 + func DeleteAccount(db *sqlx.DB, username string) error { 125 + _, err := db.Exec("DELETE FROM account WHERE username=$1", username) 113 126 return err 114 127 } 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 - }
+67
controller/invite.go
··· 1 + package controller 2 + 3 + import ( 4 + "arimelody-web/model" 5 + "math/rand" 6 + "strings" 7 + "time" 8 + 9 + "github.com/jmoiron/sqlx" 10 + ) 11 + 12 + var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 13 + 14 + func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) { 15 + invite := model.Invite{} 16 + 17 + err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code) 18 + if err != nil { 19 + if strings.Contains(err.Error(), "no rows") { 20 + return nil, nil 21 + } 22 + return nil, err 23 + } 24 + 25 + return &invite, nil 26 + } 27 + 28 + func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invite, error) { 29 + invite := model.Invite{ 30 + CreatedAt: time.Now(), 31 + ExpiresAt: time.Now().Add(lifetime), 32 + } 33 + 34 + code := []byte{} 35 + for i := 0; i < length; i++ { 36 + code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)]) 37 + } 38 + invite.Code = string(code) 39 + 40 + _, err := db.Exec( 41 + "INSERT INTO invite (code, created_at, expires_at) " + 42 + "VALUES ($1, $2, $3)", 43 + invite.Code, 44 + invite.CreatedAt, 45 + invite.ExpiresAt, 46 + ) 47 + if err != nil { 48 + return nil, err 49 + } 50 + 51 + return &invite, nil 52 + } 53 + 54 + func DeleteInvite(db *sqlx.DB, code string) error { 55 + _, err := db.Exec("DELETE FROM invite WHERE code=$1", code) 56 + return err 57 + } 58 + 59 + func DeleteAllInvites(db *sqlx.DB) error { 60 + _, err := db.Exec("DELETE FROM invite") 61 + return err 62 + } 63 + 64 + func DeleteExpiredInvites(db *sqlx.DB) error { 65 + _, err := db.Exec("DELETE FROM invite WHERE expires_at<current_timestamp") 66 + return err 67 + }
+86
controller/migrator.go
··· 1 + package controller 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "time" 7 + 8 + "github.com/jmoiron/sqlx" 9 + ) 10 + 11 + const DB_VERSION int = 2 12 + 13 + func CheckDBVersionAndMigrate(db *sqlx.DB) { 14 + db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody") 15 + db.MustExec("SET search_path TO arimelody, public") 16 + db.MustExec( 17 + "CREATE TABLE IF NOT EXISTS arimelody.schema_version (" + 18 + "version INTEGER PRIMARY KEY, " + 19 + "applied_at TIMESTAMP DEFAULT current_timestamp)", 20 + ) 21 + 22 + oldDBVersion := 0 23 + 24 + err := db.Get(&oldDBVersion, "SELECT MAX(version) FROM schema_version") 25 + if err != nil { panic(err) } 26 + 27 + for oldDBVersion < DB_VERSION { 28 + switch oldDBVersion { 29 + case 0: 30 + // default case; assume no database exists 31 + ApplyMigration(db, "000-init") 32 + oldDBVersion = DB_VERSION 33 + 34 + case 1: 35 + // the irony is i actually have to awkwardly shove schema_version 36 + // into the old database in order for this to work LOL 37 + ApplyMigration(db, "001-pre-versioning") 38 + oldDBVersion = 2 39 + 40 + } 41 + } 42 + 43 + fmt.Printf("Database schema up to date.\n") 44 + } 45 + 46 + func ApplyMigration(db *sqlx.DB, scriptFile string) { 47 + fmt.Printf("Applying schema migration %s...\n", scriptFile) 48 + 49 + bytes, err := os.ReadFile("schema_migration/" + scriptFile + ".sql") 50 + if err != nil { 51 + fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err) 52 + os.Exit(1) 53 + } 54 + script := string(bytes) 55 + 56 + tx, err := db.Begin() 57 + if err != nil { 58 + fmt.Fprintf(os.Stderr, "FATAL: Failed to begin migration: %v\n", err) 59 + os.Exit(1) 60 + } 61 + 62 + _, err = tx.Exec(script) 63 + if err != nil { 64 + tx.Rollback() 65 + fmt.Fprintf(os.Stderr, "FATAL: Failed to apply migration: %v\n", err) 66 + os.Exit(1) 67 + } 68 + 69 + _, err = tx.Exec( 70 + "INSERT INTO schema_version (version, applied_at) " + 71 + "VALUES ($1, $2)", 72 + DB_VERSION, 73 + time.Now(), 74 + ) 75 + if err != nil { 76 + tx.Rollback() 77 + fmt.Fprintf(os.Stderr, "FATAL: Failed to update schema version: %v\n", err) 78 + os.Exit(1) 79 + } 80 + 81 + err = tx.Commit() 82 + if err != nil { 83 + fmt.Fprintf(os.Stderr, "FATAL: Failed to commit transaction: %v\n", err) 84 + os.Exit(1) 85 + } 86 + }
+2 -29
global/config.go
··· 5 5 "fmt" 6 6 "os" 7 7 "strconv" 8 - "strings" 9 8 10 9 "github.com/jmoiron/sqlx" 11 10 "github.com/pelletier/go-toml/v2" ··· 57 56 58 57 err = toml.Unmarshal([]byte(data), &config) 59 58 if err != nil { 60 - fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %s\n", err.Error()) 59 + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse configuration file: %v\n", err) 61 60 os.Exit(1) 62 61 } 63 62 64 63 err = handleConfigOverrides(&config) 65 64 if err != nil { 66 - fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %s\n", err.Error()) 65 + fmt.Fprintf(os.Stderr, "FATAL: Failed to parse environment variable %v\n", err) 67 66 os.Exit(1) 68 67 } 69 68 ··· 91 90 92 91 return nil 93 92 } 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 93 121 94 var DB *sqlx.DB
+11 -11
global/funcs.go
··· 58 58 59 59 type LoggingResponseWriter struct { 60 60 http.ResponseWriter 61 - Code int 61 + Status int 62 62 } 63 63 64 - func (lrw *LoggingResponseWriter) WriteHeader(code int) { 65 - lrw.Code = code 66 - lrw.ResponseWriter.WriteHeader(code) 64 + func (lrw *LoggingResponseWriter) WriteHeader(status int) { 65 + lrw.Status = status 66 + lrw.ResponseWriter.WriteHeader(status) 67 67 } 68 68 69 69 func HTTPLog(next http.Handler) http.Handler { ··· 81 81 elapsed = strconv.Itoa(difference) 82 82 } 83 83 84 - codeColour := colour.Reset 84 + statusColour := colour.Reset 85 85 86 - if lrw.Code - 600 <= 0 { codeColour = colour.Red } 87 - if lrw.Code - 500 <= 0 { codeColour = colour.Yellow } 88 - if lrw.Code - 400 <= 0 { codeColour = colour.White } 89 - if lrw.Code - 300 <= 0 { codeColour = colour.Green } 86 + if lrw.Status - 600 <= 0 { statusColour = colour.Red } 87 + if lrw.Status - 500 <= 0 { statusColour = colour.Yellow } 88 + if lrw.Status - 400 <= 0 { statusColour = colour.White } 89 + if lrw.Status - 300 <= 0 { statusColour = colour.Green } 90 90 91 91 fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", 92 92 after.Format(time.UnixDate), 93 93 r.Method, 94 94 r.URL.Path, 95 - codeColour, 96 - lrw.Code, 95 + statusColour, 96 + lrw.Status, 97 97 colour.Reset, 98 98 elapsed, 99 99 r.Header["User-Agent"][0])
+97 -158
main.go
··· 7 7 "net/http" 8 8 "os" 9 9 "path/filepath" 10 + "strings" 10 11 "time" 11 12 12 13 "arimelody-web/admin" 13 14 "arimelody-web/api" 15 + "arimelody-web/controller" 14 16 "arimelody-web/global" 17 + "arimelody-web/templates" 15 18 "arimelody-web/view" 16 - "arimelody-web/controller" 17 - "arimelody-web/templates" 18 19 19 20 "github.com/jmoiron/sqlx" 20 21 _ "github.com/lib/pq" 21 22 ) 22 23 24 + // used for database migrations 25 + const DB_VERSION = 1 26 + 23 27 const DEFAULT_PORT int64 = 8080 24 28 25 29 func main() { 30 + fmt.Printf("made with <3 by ari melody\n\n") 31 + 26 32 // initialise database connection 27 33 if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } 28 34 if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } ··· 65 71 global.DB.SetMaxIdleConns(10) 66 72 defer global.DB.Close() 67 73 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) 74 + // handle command arguments 75 + if len(os.Args) > 1 { 76 + arg := os.Args[1] 77 + 78 + switch arg { 79 + case "createInvite": 80 + fmt.Printf("Creating invite...\n") 81 + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) 82 + if err != nil { 83 + fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) 84 + os.Exit(1) 85 + } 86 + 87 + fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code) 88 + return 89 + 90 + case "purgeInvites": 91 + fmt.Printf("Deleting all invites...\n") 92 + err := controller.DeleteAllInvites(global.DB) 93 + if err != nil { 94 + fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) 95 + os.Exit(1) 96 + } 97 + 98 + fmt.Printf("Invites deleted successfully.\n") 99 + return 100 + 101 + case "deleteAccount": 102 + if len(os.Args) < 2 { 103 + fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") 104 + os.Exit(1) 105 + } 106 + username := os.Args[2] 107 + fmt.Printf("Deleting account \"%s\"...\n", username) 108 + 109 + account, err := controller.GetAccount(global.DB, username) 110 + if err != nil { 111 + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %s\n", username, err.Error()) 112 + os.Exit(1) 113 + } 114 + 115 + if account == nil { 116 + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) 117 + os.Exit(1) 118 + } 119 + 120 + fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) 121 + res := "" 122 + fmt.Scanln(&res) 123 + if !strings.HasPrefix(res, "y") { 124 + return 125 + } 126 + 127 + err = controller.DeleteAccount(global.DB, username) 128 + if err != nil { 129 + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) 130 + os.Exit(1) 131 + } 132 + 133 + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) 134 + return 135 + 136 + } 137 + 138 + fmt.Printf( 139 + "Available commands:\n\n" + 140 + "createInvite:\n\tCreates an invite code to register new accounts.\n" + 141 + "purgeInvites:\n\tDeletes all available invite codes.\n" + 142 + "deleteAccount <username>:\n\tDeletes an account with a given `username`.\n", 143 + ) 144 + return 72 145 } 73 146 147 + // handle DB migrations 148 + controller.CheckDBVersionAndMigrate(global.DB) 149 + 150 + // initial invite code 74 151 accountsCount := 0 75 - global.DB.Get(&accountsCount, "SELECT count(*) FROM account") 152 + err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") 153 + if err != nil { panic(err) } 76 154 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") 155 + _, err := global.DB.Exec("DELETE FROM invite") 85 156 if err != nil { 86 157 fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) 87 158 os.Exit(1) 88 159 } 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() 160 + 161 + invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) 95 162 if err != nil { 96 - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite codes: %v\n", err) 163 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) 97 164 os.Exit(1) 98 165 } 99 166 100 - fmt.Fprintln(os.Stdout, "INFO: No accounts exist! Generated invite code: " + string(code) + " (Use this at /register or /api/v1/register)") 167 + fmt.Fprintf(os.Stdout, "No accounts exist! Generated invite code: " + string(invite.Code) + "\nUse this at %s/admin/register.\n", global.Config.BaseUrl) 168 + } 169 + 170 + // delete expired invites 171 + err = controller.DeleteExpiredInvites(global.DB) 172 + if err != nil { 173 + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) 174 + os.Exit(1) 101 175 } 102 176 103 177 // start the web server! ··· 107 181 http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), 108 182 global.HTTPLog(global.DefaultHeaders(mux)), 109 183 )) 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 245 184 } 246 185 247 186 func createServeMux() *http.ServeMux {
+1 -12
model/account.go
··· 1 1 package model 2 2 3 - import ( 4 - "time" 5 - ) 6 - 7 3 type ( 8 4 Account struct { 9 5 ID string `json:"id" db:"id"` 10 6 Username string `json:"username" db:"username"` 11 - Password []byte `json:"password" db:"password"` 7 + Password string `json:"password" db:"password"` 12 8 Email string `json:"email" db:"email"` 13 9 AvatarURL string `json:"avatar_url" db:"avatar_url"` 14 10 Privileges []AccountPrivilege `json:"privileges"` 15 11 } 16 12 17 13 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 14 ) 26 15 27 16 const (
+10
model/invite.go
··· 1 + package model 2 + 3 + import "time" 4 + 5 + type Invite struct { 6 + Code string `db:"code"` 7 + CreatedByID string `db:"created_by"` 8 + CreatedAt time.Time `db:"created_at"` 9 + ExpiresAt time.Time `db:"expires_at"` 10 + }
+13 -22
schema.sql schema_migration/000-init.sql
··· 1 - CREATE SCHEMA arimelody AUTHORIZATION arimelody; 1 + CREATE SCHEMA arimelody; 2 + 3 + -- Schema verison 4 + CREATE TABLE arimelody.schema_version ( 5 + version INTEGER PRIMARY KEY, 6 + applied_at TIMESTAMP DEFAULT current_timestamp 7 + ); 2 8 3 9 -- 4 - -- Acounts 10 + -- Tables 5 11 -- 12 + 13 + -- Accounts 6 14 CREATE TABLE arimelody.account ( 7 15 id uuid DEFAULT gen_random_uuid(), 8 16 username text NOT NULL UNIQUE, ··· 12 20 ); 13 21 ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); 14 22 15 - -- 16 23 -- Privilege 17 - -- 18 24 CREATE TABLE arimelody.privilege ( 19 25 account uuid NOT NULL, 20 26 privilege text NOT NULL 21 27 ); 22 28 ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); 23 29 24 - -- 25 30 -- TOTP 26 - -- 27 31 CREATE TABLE arimelody.totp ( 28 32 account uuid NOT NULL, 29 33 name text NOT NULL, ··· 31 35 ); 32 36 ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); 33 37 34 - -- 35 38 -- Invites 36 - -- 37 39 CREATE TABLE arimelody.invite ( 38 40 code text NOT NULL, 39 41 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, ··· 41 43 ); 42 44 ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); 43 45 44 - -- 45 46 -- Tokens 46 - -- 47 47 CREATE TABLE arimelody.token ( 48 48 token TEXT, 49 49 account UUID NOT NULL, ··· 54 54 ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); 55 55 56 56 57 - -- 58 57 -- Artists (should be applicable to all art) 59 - -- 60 58 CREATE TABLE arimelody.artist ( 61 59 id character varying(64), 62 60 name text NOT NULL, ··· 65 63 ); 66 64 ALTER TABLE arimelody.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); 67 65 68 - -- 69 66 -- Music releases 70 - -- 71 67 CREATE TABLE arimelody.musicrelease ( 72 68 id character varying(64) NOT NULL, 73 69 visible bool DEFAULT false, ··· 83 79 ); 84 80 ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); 85 81 86 - -- 87 82 -- Music links (external platform links under a release) 88 - -- 89 83 CREATE TABLE arimelody.musiclink ( 90 84 release character varying(64) NOT NULL, 91 85 name text NOT NULL, ··· 93 87 ); 94 88 ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); 95 89 96 - -- 97 90 -- Music credits (artist credits under a release) 98 - -- 99 91 CREATE TABLE arimelody.musiccredit ( 100 92 release character varying(64) NOT NULL, 101 93 artist character varying(64) NOT NULL, ··· 104 96 ); 105 97 ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); 106 98 107 - -- 108 99 -- Music tracks (tracks under a release) 109 - -- 110 100 CREATE TABLE arimelody.musictrack ( 111 101 id uuid DEFAULT gen_random_uuid(), 112 102 title text NOT NULL, ··· 116 106 ); 117 107 ALTER TABLE arimelody.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); 118 108 119 - -- 120 109 -- Music release/track pairs 121 - -- 122 110 CREATE TABLE arimelody.musicreleasetrack ( 123 111 release character varying(64) NOT NULL, 124 112 track uuid NOT NULL, ··· 126 114 ); 127 115 ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIMARY KEY (release, track); 128 116 117 + 118 + 129 119 -- 130 120 -- Foreign keys 131 121 -- 122 + 132 123 ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 133 124 ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 134 125 ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
+65
schema_migration/001-pre-versioning.sql
··· 1 + -- 2 + -- Migration 3 + -- 4 + 5 + -- Move existing tables to new schema 6 + ALTER TABLE public.artist SET SCHEMA arimelody; 7 + ALTER TABLE public.musicrelease SET SCHEMA arimelody; 8 + ALTER TABLE public.musiclink SET SCHEMA arimelody; 9 + ALTER TABLE public.musiccredit SET SCHEMA arimelody; 10 + ALTER TABLE public.musictrack SET SCHEMA arimelody; 11 + ALTER TABLE public.musicreleasetrack SET SCHEMA arimelody; 12 + 13 + 14 + 15 + -- 16 + -- New items 17 + -- 18 + 19 + -- Acounts 20 + CREATE TABLE arimelody.account ( 21 + id uuid DEFAULT gen_random_uuid(), 22 + username text NOT NULL UNIQUE, 23 + password text NOT NULL, 24 + email text, 25 + avatar_url text 26 + ); 27 + ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); 28 + 29 + -- Privilege 30 + CREATE TABLE arimelody.privilege ( 31 + account uuid NOT NULL, 32 + privilege text NOT NULL 33 + ); 34 + ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); 35 + 36 + -- TOTP 37 + CREATE TABLE arimelody.totp ( 38 + account uuid NOT NULL, 39 + name text NOT NULL, 40 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 41 + ); 42 + ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); 43 + 44 + -- Invites 45 + CREATE TABLE arimelody.invite ( 46 + code text NOT NULL, 47 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 48 + expires_at TIMESTAMP NOT NULL 49 + ); 50 + ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); 51 + 52 + -- Tokens 53 + CREATE TABLE arimelody.token ( 54 + token TEXT, 55 + account UUID NOT NULL, 56 + user_agent TEXT NOT NULL, 57 + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, 58 + expires_at TIMESTAMP DEFAULT NULL 59 + ); 60 + ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); 61 + 62 + -- Foreign keys 63 + ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 64 + ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 65 + ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
+1 -1
view/music.go
··· 63 63 if !release.Visible { 64 64 account, err := controller.GetAccountByRequest(global.DB, r) 65 65 if err != nil { 66 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error()) 66 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 67 67 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 68 68 return 69 69 }