home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

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

merge feature/accountsettings into dev

+1607 -829
+248 -211
admin/accounthttp.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net/http" 6 + "net/url" 6 7 "os" 7 - "strings" 8 8 "time" 9 9 10 10 "arimelody-web/controller" ··· 13 13 "golang.org/x/crypto/bcrypt" 14 14 ) 15 15 16 - type TemplateData struct { 17 - Account *model.Account 18 - Message string 19 - Token string 16 + func accountHandler(app *model.AppState) http.Handler { 17 + mux := http.NewServeMux() 18 + 19 + mux.Handle("/totp-setup", totpSetupHandler(app)) 20 + mux.Handle("/totp-confirm", totpConfirmHandler(app)) 21 + mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) 22 + 23 + mux.Handle("/password", changePasswordHandler(app)) 24 + mux.Handle("/delete", deleteAccountHandler(app)) 25 + 26 + return mux 20 27 } 21 28 22 - func AccountHandler(app *model.AppState) http.Handler { 29 + func accountIndexHandler(app *model.AppState) http.Handler { 23 30 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 - account := r.Context().Value("account").(*model.Account) 31 + session := r.Context().Value("session").(*model.Session) 25 32 26 - totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) 33 + dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) 27 34 if err != nil { 28 35 fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err) 29 36 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 30 37 } 31 38 32 - type AccountResponse struct { 33 - Account *model.Account 34 - TOTPs []model.TOTP 39 + type ( 40 + TOTP struct { 41 + model.TOTP 42 + CreatedAtString string 43 + } 44 + 45 + accountResponse struct { 46 + Session *model.Session 47 + TOTPs []TOTP 48 + } 49 + ) 50 + 51 + totps := []TOTP{} 52 + for _, totp := range dbTOTPs { 53 + totps = append(totps, TOTP{ 54 + TOTP: totp, 55 + CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"), 56 + }) 35 57 } 36 58 37 - err = pages["account"].Execute(w, AccountResponse{ 38 - Account: account, 59 + sessionMessage := session.Message 60 + sessionError := session.Error 61 + controller.SetSessionMessage(app.DB, session, "") 62 + controller.SetSessionError(app.DB, session, "") 63 + session.Message = sessionMessage 64 + session.Error = sessionError 65 + 66 + err = accountTemplate.Execute(w, accountResponse{ 67 + Session: session, 39 68 TOTPs: totps, 40 69 }) 41 70 if err != nil { ··· 45 74 }) 46 75 } 47 76 48 - func LoginHandler(app *model.AppState) http.Handler { 77 + func changePasswordHandler(app *model.AppState) http.Handler { 49 78 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 - if r.Method == http.MethodGet { 51 - account, err := controller.GetAccountByRequest(app.DB, r) 52 - if err != nil { 53 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 54 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 55 - return 56 - } 57 - if account != nil { 58 - http.Redirect(w, r, "/admin", http.StatusFound) 59 - return 60 - } 61 - 62 - err = pages["login"].Execute(w, TemplateData{}) 63 - if err != nil { 64 - fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) 65 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 66 - return 67 - } 79 + if r.Method != http.MethodPost { 80 + http.NotFound(w, r) 68 81 return 69 82 } 70 83 71 - type LoginResponse struct { 72 - Account *model.Account 73 - Token string 74 - Message string 75 - } 84 + session := r.Context().Value("session").(*model.Session) 85 + 86 + controller.SetSessionMessage(app.DB, session, "") 87 + controller.SetSessionError(app.DB, session, "") 76 88 77 - render := func(data LoginResponse) { 78 - err := pages["login"].Execute(w, data) 79 - if err != nil { 80 - fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) 81 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 82 - return 83 - } 84 - } 89 + r.ParseForm() 85 90 86 - if r.Method != http.MethodPost { 87 - http.NotFound(w, r); 91 + currentPassword := r.Form.Get("current-password") 92 + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil { 93 + controller.SetSessionError(app.DB, session, "Incorrect password.") 94 + http.Redirect(w, r, "/admin/account", http.StatusFound) 88 95 return 89 96 } 90 97 91 - err := r.ParseForm() 98 + newPassword := r.Form.Get("new-password") 99 + 100 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) 92 101 if err != nil { 93 - render(LoginResponse{ Message: "Malformed request." }) 102 + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) 103 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 104 + http.Redirect(w, r, "/admin/account", http.StatusFound) 94 105 return 95 106 } 96 107 97 - type LoginRequest struct { 98 - Username string `json:"username"` 99 - Password string `json:"password"` 100 - TOTP string `json:"totp"` 108 + session.Account.Password = string(hashedPassword) 109 + err = controller.UpdateAccount(app.DB, session.Account) 110 + if err != nil { 111 + fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err) 112 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 113 + http.Redirect(w, r, "/admin/account", http.StatusFound) 114 + return 101 115 } 102 - credentials := LoginRequest{ 103 - Username: r.Form.Get("username"), 104 - Password: r.Form.Get("password"), 105 - TOTP: r.Form.Get("totp"), 116 + 117 + controller.SetSessionError(app.DB, session, "") 118 + controller.SetSessionMessage(app.DB, session, "Password updated successfully.") 119 + http.Redirect(w, r, "/admin/account", http.StatusFound) 120 + }) 121 + } 122 + 123 + func deleteAccountHandler(app *model.AppState) http.Handler { 124 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 125 + if r.Method != http.MethodPost { 126 + http.NotFound(w, r) 127 + return 106 128 } 107 129 108 - account, err := controller.GetAccount(app.DB, credentials.Username) 130 + err := r.ParseForm() 109 131 if err != nil { 110 - render(LoginResponse{ Message: "Invalid username or password" }) 132 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 111 133 return 112 134 } 113 - if account == nil { 114 - render(LoginResponse{ Message: "Invalid username or password" }) 135 + 136 + if !r.Form.Has("password") || !r.Form.Has("totp") { 137 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 115 138 return 116 139 } 117 140 118 - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) 119 - if err != nil { 120 - render(LoginResponse{ Message: "Invalid username or password" }) 141 + session := r.Context().Value("session").(*model.Session) 142 + 143 + // check password 144 + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { 145 + fmt.Printf( 146 + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n", 147 + time.Now().Format(time.UnixDate), 148 + session.Account.Username, 149 + ) 150 + controller.SetSessionError(app.DB, session, "Incorrect password.") 151 + http.Redirect(w, r, "/admin/account", http.StatusFound) 121 152 return 122 153 } 123 154 124 - totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) 155 + totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp")) 125 156 if err != nil { 126 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) 127 - render(LoginResponse{ Message: "Something went wrong. Please try again." }) 157 + fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err) 158 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 159 + http.Redirect(w, r, "/admin/account", http.StatusFound) 128 160 return 129 161 } 130 - if len(totps) > 0 { 131 - success := false 132 - for _, totp := range totps { 133 - check := controller.GenerateTOTP(totp.Secret, 0) 134 - if check == credentials.TOTP { 135 - success = true 136 - break 137 - } 138 - } 139 - if !success { 140 - render(LoginResponse{ Message: "Invalid TOTP" }) 141 - return 142 - } 143 - } else { 144 - // TODO: user should be prompted to add 2FA method 162 + if totpMethod == nil { 163 + fmt.Printf( 164 + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n", 165 + time.Now().Format(time.UnixDate), 166 + session.Account.Username, 167 + ) 168 + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") 169 + http.Redirect(w, r, "/admin/account", http.StatusFound) 145 170 } 146 171 147 - // login success! 148 - token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) 172 + err = controller.DeleteAccount(app.DB, session.Account.ID) 149 173 if err != nil { 150 - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) 151 - render(LoginResponse{ Message: "Something went wrong. Please try again." }) 174 + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) 175 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 176 + http.Redirect(w, r, "/admin/account", http.StatusFound) 152 177 return 153 178 } 154 179 155 - cookie := http.Cookie{} 156 - cookie.Name = model.COOKIE_TOKEN 157 - cookie.Value = token.Token 158 - cookie.Expires = token.ExpiresAt 159 - if strings.HasPrefix(app.Config.BaseUrl, "https") { 160 - cookie.Secure = true 161 - } 162 - cookie.HttpOnly = true 163 - cookie.Path = "/" 164 - http.SetCookie(w, &cookie) 180 + fmt.Printf( 181 + "[%s] INFO: Account \"%s\" deleted by user request.\n", 182 + time.Now().Format(time.UnixDate), 183 + session.Account.Username, 184 + ) 165 185 166 - render(LoginResponse{ Account: account, Token: token.Token }) 186 + controller.SetSessionAccount(app.DB, session, nil) 187 + controller.SetSessionError(app.DB, session, "") 188 + controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") 189 + http.Redirect(w, r, "/admin/login", http.StatusFound) 167 190 }) 168 191 } 169 192 170 - func LogoutHandler(app *model.AppState) http.Handler { 193 + func totpSetupHandler(app *model.AppState) http.Handler { 171 194 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 172 - if r.Method != http.MethodGet { 173 - http.NotFound(w, r) 174 - return 175 - } 195 + if r.Method == http.MethodGet { 196 + type totpSetupData struct { 197 + Session *model.Session 198 + } 176 199 177 - tokenStr := controller.GetTokenFromRequest(app.DB, r) 200 + session := r.Context().Value("session").(*model.Session) 178 201 179 - if len(tokenStr) > 0 { 180 - err := controller.DeleteToken(app.DB, tokenStr) 202 + err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) 181 203 if err != nil { 182 - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) 204 + fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 183 205 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 184 - return 185 206 } 207 + return 186 208 } 187 209 188 - cookie := http.Cookie{} 189 - cookie.Name = model.COOKIE_TOKEN 190 - cookie.Value = "" 191 - cookie.Expires = time.Now() 192 - if strings.HasPrefix(app.Config.BaseUrl, "https") { 193 - cookie.Secure = true 210 + if r.Method != http.MethodPost { 211 + http.NotFound(w, r) 212 + return 194 213 } 195 - cookie.HttpOnly = true 196 - cookie.Path = "/" 197 - http.SetCookie(w, &cookie) 198 - http.Redirect(w, r, "/admin/login", http.StatusFound) 199 - }) 200 - } 201 214 202 - func createAccountHandler(app *model.AppState) http.Handler { 203 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 204 - checkAccount, err := controller.GetAccountByRequest(app.DB, r) 215 + type totpSetupData struct { 216 + Session *model.Session 217 + TOTP *model.TOTP 218 + NameEscaped string 219 + QRBase64Image string 220 + } 221 + 222 + err := r.ParseForm() 205 223 if err != nil { 206 - fmt.Printf("WARN: Failed to fetch account: %s\n", err) 207 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 224 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 208 225 return 209 226 } 210 - if checkAccount != nil { 211 - // user is already logged in 212 - http.Redirect(w, r, "/admin", http.StatusFound) 227 + 228 + name := r.FormValue("totp-name") 229 + if len(name) == 0 { 230 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 213 231 return 214 232 } 215 233 216 - type CreateAccountResponse struct { 217 - Account *model.Account 218 - Message string 234 + session := r.Context().Value("session").(*model.Session) 235 + 236 + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) 237 + totp := model.TOTP { 238 + AccountID: session.Account.ID, 239 + Name: name, 240 + Secret: string(secret), 241 + } 242 + err = controller.CreateTOTP(app.DB, &totp) 243 + if err != nil { 244 + fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) 245 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 246 + err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) 247 + if err != nil { 248 + fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 249 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 250 + } 251 + return 219 252 } 220 253 221 - render := func(data CreateAccountResponse) { 222 - err := pages["create-account"].Execute(w, data) 254 + qrBase64Image, err := controller.GenerateQRCode( 255 + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) 256 + if err != nil { 257 + fmt.Printf("WARN: Failed to generate TOTP setup QR code: %s\n", err) 258 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 259 + err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) 223 260 if err != nil { 224 - fmt.Printf("WARN: Error rendering create account page: %s\n", err) 261 + fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 225 262 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 226 263 } 264 + return 227 265 } 228 266 229 - if r.Method == http.MethodGet { 230 - render(CreateAccountResponse{}) 231 - return 267 + err = totpConfirmTemplate.Execute(w, totpSetupData{ 268 + Session: session, 269 + TOTP: &totp, 270 + NameEscaped: url.PathEscape(totp.Name), 271 + QRBase64Image: qrBase64Image, 272 + }) 273 + if err != nil { 274 + fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err) 275 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 232 276 } 277 + }) 278 + } 233 279 280 + func totpConfirmHandler(app *model.AppState) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 234 282 if r.Method != http.MethodPost { 235 283 http.NotFound(w, r) 236 284 return 237 285 } 238 286 239 - err = r.ParseForm() 240 - if err != nil { 241 - render(CreateAccountResponse{ 242 - Message: "Malformed data.", 243 - }) 244 - return 287 + type totpConfirmData struct { 288 + Session *model.Session 289 + TOTP *model.TOTP 245 290 } 246 291 247 - type RegisterRequest struct { 248 - Username string `json:"username"` 249 - Email string `json:"email"` 250 - Password string `json:"password"` 251 - Invite string `json:"invite"` 252 - } 253 - credentials := RegisterRequest{ 254 - Username: r.Form.Get("username"), 255 - Email: r.Form.Get("email"), 256 - Password: r.Form.Get("password"), 257 - Invite: r.Form.Get("invite"), 258 - } 292 + session := r.Context().Value("session").(*model.Session) 259 293 260 - // make sure code exists in DB 261 - invite, err := controller.GetInvite(app.DB, credentials.Invite) 294 + err := r.ParseForm() 262 295 if err != nil { 263 - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) 264 - render(CreateAccountResponse{ 265 - Message: "Something went wrong. Please try again.", 266 - }) 296 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 297 + return 298 + } 299 + name := r.FormValue("totp-name") 300 + if len(name) == 0 { 301 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 267 302 return 268 303 } 269 - if invite == nil || time.Now().After(invite.ExpiresAt) { 270 - if invite != nil { 271 - err := controller.DeleteInvite(app.DB, invite.Code) 272 - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 273 - } 274 - render(CreateAccountResponse{ 275 - Message: "Invalid invite code.", 276 - }) 304 + code := r.FormValue("totp") 305 + if len(code) != controller.TOTP_CODE_LENGTH { 306 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 277 307 return 278 308 } 279 309 280 - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) 310 + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) 281 311 if err != nil { 282 - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) 283 - render(CreateAccountResponse{ 284 - Message: "Something went wrong. Please try again.", 285 - }) 312 + fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) 313 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 314 + http.Redirect(w, r, "/admin/account", http.StatusFound) 315 + return 316 + } 317 + if totp == nil { 318 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 286 319 return 287 320 } 288 321 289 - account := model.Account{ 290 - Username: credentials.Username, 291 - Password: string(hashedPassword), 292 - Email: credentials.Email, 293 - AvatarURL: "/img/default-avatar.png", 294 - } 295 - err = controller.CreateAccount(app.DB, &account) 296 - if err != nil { 297 - if strings.HasPrefix(err.Error(), "pq: duplicate key") { 298 - render(CreateAccountResponse{ 299 - Message: "An account with that username already exists.", 322 + confirmCode := controller.GenerateTOTP(totp.Secret, 0) 323 + if code != confirmCode { 324 + confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) 325 + if code != confirmCodeOffset { 326 + controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.") 327 + err = totpConfirmTemplate.Execute(w, totpConfirmData{ 328 + Session: session, 329 + TOTP: totp, 300 330 }) 301 331 return 302 332 } 303 - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) 304 - render(CreateAccountResponse{ 305 - Message: "Something went wrong. Please try again.", 306 - }) 333 + } 334 + 335 + controller.SetSessionError(app.DB, session, "") 336 + controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) 337 + http.Redirect(w, r, "/admin/account", http.StatusFound) 338 + }) 339 + } 340 + 341 + func totpDeleteHandler(app *model.AppState) http.Handler { 342 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 343 + if r.Method != http.MethodGet { 344 + http.NotFound(w, r) 307 345 return 308 346 } 309 347 310 - err = controller.DeleteInvite(app.DB, invite.Code) 311 - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 348 + if len(r.URL.Path) < 2 { 349 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 350 + return 351 + } 352 + name := r.URL.Path[1:] 312 353 313 - // registration success! 314 - token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) 354 + session := r.Context().Value("session").(*model.Session) 355 + 356 + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) 315 357 if err != nil { 316 - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) 317 - // gracefully redirect user to login page 318 - http.Redirect(w, r, "/admin/login", http.StatusFound) 358 + fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) 359 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 360 + http.Redirect(w, r, "/admin/account", http.StatusFound) 319 361 return 320 362 } 321 - 322 - cookie := http.Cookie{} 323 - cookie.Name = model.COOKIE_TOKEN 324 - cookie.Value = token.Token 325 - cookie.Expires = token.ExpiresAt 326 - if strings.HasPrefix(app.Config.BaseUrl, "https") { 327 - cookie.Secure = true 363 + if totp == nil { 364 + http.NotFound(w, r) 365 + return 328 366 } 329 - cookie.HttpOnly = true 330 - cookie.Path = "/" 331 - http.SetCookie(w, &cookie) 332 367 333 - err = pages["login"].Execute(w, TemplateData{ 334 - Account: &account, 335 - Token: token.Token, 336 - }) 368 + err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) 337 369 if err != nil { 338 - fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) 339 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 370 + fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err) 371 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 372 + http.Redirect(w, r, "/admin/account", http.StatusFound) 340 373 return 341 374 } 375 + 376 + controller.SetSessionError(app.DB, session, "") 377 + controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) 378 + http.Redirect(w, r, "/admin/account", http.StatusFound) 342 379 }) 343 380 }
+4 -4
admin/artisthttp.go
··· 32 32 } 33 33 34 34 type ArtistResponse struct { 35 - Account *model.Account 35 + Session *model.Session 36 36 Artist *model.Artist 37 37 Credits []*model.Credit 38 38 } 39 39 40 - account := r.Context().Value("account").(*model.Account) 40 + session := r.Context().Value("session").(*model.Session) 41 41 42 - err = pages["artist"].Execute(w, ArtistResponse{ 43 - Account: account, 42 + err = artistTemplate.Execute(w, ArtistResponse{ 43 + Session: session, 44 44 Artist: artist, 45 45 Credits: credits, 46 46 })
+5 -5
admin/components/tracks/edittracks.html
··· 3 3 <h2>Editing: Tracks</h2> 4 4 <a id="add-track" 5 5 class="button new" 6 - href="/admin/release/{{.ID}}/addtrack" 7 - hx-get="/admin/release/{{.ID}}/addtrack" 6 + href="/admin/release/{{.Release.ID}}/addtrack" 7 + hx-get="/admin/release/{{.Release.ID}}/addtrack" 8 8 hx-target="body" 9 9 hx-swap="beforeend" 10 10 >Add</a> 11 11 </header> 12 12 13 - <form action="/api/v1/music/{{.ID}}/tracks"> 13 + <form action="/api/v1/music/{{.Release.ID}}/tracks"> 14 14 <ul> 15 - {{range $i, $track := .Tracks}} 15 + {{range $i, $track := .Release.Tracks}} 16 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">{{$track.Add $i 1}}</span> 19 + <span class="track-number">{{.Add $i 1}}</span> 20 20 {{$track.Title}} 21 21 </p> 22 22 <a class="delete">Delete</a>
+385 -37
admin/http.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "fmt" 6 7 "net/http" 7 8 "os" 8 9 "path/filepath" 10 + "strings" 11 + "time" 9 12 10 13 "arimelody-web/controller" 11 14 "arimelody-web/model" 15 + 16 + "golang.org/x/crypto/bcrypt" 12 17 ) 13 18 14 19 func Handler(app *model.AppState) http.Handler { 15 20 mux := http.NewServeMux() 16 21 17 - mux.Handle("/login", LoginHandler(app)) 18 - mux.Handle("/register", createAccountHandler(app)) 19 - mux.Handle("/logout", RequireAccount(app, LogoutHandler(app))) 20 - mux.Handle("/account", RequireAccount(app, AccountHandler(app))) 22 + mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 + qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family") 24 + if err != nil { 25 + fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err) 26 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 27 + return 28 + } 29 + 30 + w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>")) 31 + })) 32 + 33 + mux.Handle("/login", loginHandler(app)) 34 + mux.Handle("/logout", requireAccount(app, logoutHandler(app))) 35 + 36 + mux.Handle("/register", registerAccountHandler(app)) 37 + 38 + mux.Handle("/account", requireAccount(app, accountIndexHandler(app))) 39 + mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app)))) 40 + 41 + mux.Handle("/release/", requireAccount(app, http.StripPrefix("/release", serveRelease(app)))) 42 + mux.Handle("/artist/", requireAccount(app, http.StripPrefix("/artist", serveArtist(app)))) 43 + mux.Handle("/track/", requireAccount(app, http.StripPrefix("/track", serveTrack(app)))) 44 + 21 45 mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) 22 - mux.Handle("/release/", RequireAccount(app, http.StripPrefix("/release", serveRelease(app)))) 23 - mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app)))) 24 - mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app)))) 25 - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 + 47 + mux.Handle("/", requireAccount(app, AdminIndexHandler(app))) 48 + 49 + // response wrapper to make sure a session cookie exists 50 + return enforceSession(app, mux) 51 + } 52 + 53 + func AdminIndexHandler(app *model.AppState) http.Handler { 54 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 55 if r.URL.Path != "/" { 27 56 http.NotFound(w, r) 28 57 return 29 58 } 30 59 31 - account, err := controller.GetAccountByRequest(app.DB, r) 32 - if err != nil { 33 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) 34 - } 35 - if account == nil { 36 - http.Redirect(w, r, "/admin/login", http.StatusFound) 37 - return 38 - } 60 + session := r.Context().Value("session").(*model.Session) 39 61 40 62 releases, err := controller.GetAllReleases(app.DB, false, 0, true) 41 63 if err != nil { ··· 52 74 } 53 75 54 76 tracks, err := controller.GetOrphanTracks(app.DB) 55 - if err != nil { 77 + if err != nil { 56 78 fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) 57 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 58 - return 59 - } 79 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 80 + return 81 + } 60 82 61 83 type IndexData struct { 62 - Account *model.Account 84 + Session *model.Session 63 85 Releases []*model.Release 64 86 Artists []*model.Artist 65 87 Tracks []*model.Track 66 88 } 67 89 68 - err = pages["index"].Execute(w, IndexData{ 69 - Account: account, 90 + err = indexTemplate.Execute(w, IndexData{ 91 + Session: session, 70 92 Releases: releases, 71 93 Artists: artists, 72 94 Tracks: tracks, 73 95 }) 74 - if err != nil { 96 + if err != nil { 75 97 fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) 76 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 77 - return 78 - } 79 - })) 98 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 99 + return 100 + } 101 + }) 102 + } 103 + 104 + func registerAccountHandler(app *model.AppState) http.Handler { 105 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 + session := r.Context().Value("session").(*model.Session) 107 + 108 + if session.Account != nil { 109 + // user is already logged in 110 + http.Redirect(w, r, "/admin", http.StatusFound) 111 + return 112 + } 113 + 114 + type registerData struct { 115 + Session *model.Session 116 + } 117 + 118 + render := func() { 119 + err := registerTemplate.Execute(w, registerData{ Session: session }) 120 + if err != nil { 121 + fmt.Printf("WARN: Error rendering create account page: %s\n", err) 122 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 123 + } 124 + } 125 + 126 + if r.Method == http.MethodGet { 127 + render() 128 + return 129 + } 130 + 131 + if r.Method != http.MethodPost { 132 + http.NotFound(w, r) 133 + return 134 + } 135 + 136 + err := r.ParseForm() 137 + if err != nil { 138 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 139 + return 140 + } 141 + 142 + type RegisterRequest struct { 143 + Username string `json:"username"` 144 + Email string `json:"email"` 145 + Password string `json:"password"` 146 + Invite string `json:"invite"` 147 + } 148 + credentials := RegisterRequest{ 149 + Username: r.Form.Get("username"), 150 + Email: r.Form.Get("email"), 151 + Password: r.Form.Get("password"), 152 + Invite: r.Form.Get("invite"), 153 + } 154 + 155 + // make sure invite code exists in DB 156 + invite, err := controller.GetInvite(app.DB, credentials.Invite) 157 + if err != nil { 158 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) 159 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 160 + render() 161 + return 162 + } 163 + if invite == nil || time.Now().After(invite.ExpiresAt) { 164 + if invite != nil { 165 + err := controller.DeleteInvite(app.DB, invite.Code) 166 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 167 + } 168 + controller.SetSessionError(app.DB, session, "Invalid invite code.") 169 + render() 170 + return 171 + } 172 + 173 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) 174 + if err != nil { 175 + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) 176 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 177 + render() 178 + return 179 + } 180 + 181 + account := model.Account{ 182 + Username: credentials.Username, 183 + Password: string(hashedPassword), 184 + Email: sql.NullString{ String: credentials.Email, Valid: true }, 185 + AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true }, 186 + } 187 + err = controller.CreateAccount(app.DB, &account) 188 + if err != nil { 189 + if strings.HasPrefix(err.Error(), "pq: duplicate key") { 190 + controller.SetSessionError(app.DB, session, "An account with that username already exists.") 191 + render() 192 + return 193 + } 194 + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) 195 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 196 + render() 197 + return 198 + } 80 199 81 - return mux 200 + fmt.Printf( 201 + "[%s]: Account registered: %s (%s)\n", 202 + time.Now().Format(time.UnixDate), 203 + account.Username, 204 + account.ID, 205 + ) 206 + 207 + err = controller.DeleteInvite(app.DB, invite.Code) 208 + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } 209 + 210 + // registration success! 211 + controller.SetSessionAccount(app.DB, session, &account) 212 + controller.SetSessionMessage(app.DB, session, "") 213 + controller.SetSessionError(app.DB, session, "") 214 + http.Redirect(w, r, "/admin", http.StatusFound) 215 + }) 82 216 } 83 217 84 - func RequireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { 218 + func loginHandler(app *model.AppState) http.Handler { 85 219 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 - account, err := controller.GetAccountByRequest(app.DB, r) 220 + if r.Method != http.MethodGet && r.Method != http.MethodPost { 221 + http.NotFound(w, r) 222 + return 223 + } 224 + 225 + session := r.Context().Value("session").(*model.Session) 226 + 227 + type loginData struct { 228 + Session *model.Session 229 + } 230 + 231 + render := func() { 232 + err := loginTemplate.Execute(w, loginData{ Session: session }) 233 + if err != nil { 234 + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) 235 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 236 + return 237 + } 238 + } 239 + 240 + if r.Method == http.MethodGet { 241 + if session.Account != nil { 242 + // user is already logged in 243 + http.Redirect(w, r, "/admin", http.StatusFound) 244 + return 245 + } 246 + 247 + render() 248 + return 249 + } 250 + 251 + err := r.ParseForm() 87 252 if err != nil { 253 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 254 + return 255 + } 256 + 257 + type LoginRequest struct { 258 + Username string `json:"username"` 259 + Password string `json:"password"` 260 + TOTP string `json:"totp"` 261 + } 262 + credentials := LoginRequest{ 263 + Username: r.Form.Get("username"), 264 + Password: r.Form.Get("password"), 265 + TOTP: r.Form.Get("totp"), 266 + } 267 + 268 + account, err := controller.GetAccountByUsername(app.DB, credentials.Username) 269 + if err != nil { 270 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) 271 + controller.SetSessionError(app.DB, session, "Invalid username or password.") 272 + render() 273 + return 274 + } 275 + if account == nil { 276 + controller.SetSessionError(app.DB, session, "Invalid username or password.") 277 + render() 278 + return 279 + } 280 + 281 + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) 282 + if err != nil { 283 + fmt.Printf( 284 + "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n", 285 + time.Now().Format(time.UnixDate), 286 + account.Username, 287 + ) 288 + controller.SetSessionError(app.DB, session, "Invalid username or password.") 289 + render() 290 + return 291 + } 292 + 293 + var totpMethod *model.TOTP 294 + if len(credentials.TOTP) == 0 { 295 + // check if user has TOTP 296 + totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) 297 + if err != nil { 298 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) 299 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 300 + render() 301 + return 302 + } 303 + 304 + if len(totps) > 0 { 305 + type loginTOTPData struct { 306 + Session *model.Session 307 + Username string 308 + Password string 309 + } 310 + err = loginTOTPTemplate.Execute(w, loginTOTPData{ 311 + Session: session, 312 + Username: credentials.Username, 313 + Password: credentials.Password, 314 + }) 315 + if err != nil { 316 + fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) 317 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 318 + return 319 + } 320 + } 321 + } else { 322 + totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP) 323 + if err != nil { 324 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) 325 + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 326 + render() 327 + return 328 + } 329 + if totpMethod == nil { 330 + controller.SetSessionError(app.DB, session, "Invalid TOTP.") 331 + render() 332 + return 333 + } 334 + } 335 + 336 + if totpMethod != nil { 337 + fmt.Printf( 338 + "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", 339 + time.Now().Format(time.UnixDate), 340 + account.Username, 341 + totpMethod.Name, 342 + ) 343 + } else { 344 + fmt.Printf( 345 + "[%s] INFO: Account \"%s\" logged in\n", 346 + time.Now().Format(time.UnixDate), 347 + account.Username, 348 + ) 349 + } 350 + 351 + // TODO: log login activity to user 352 + 353 + // login success! 354 + controller.SetSessionAccount(app.DB, session, account) 355 + controller.SetSessionMessage(app.DB, session, "") 356 + controller.SetSessionError(app.DB, session, "") 357 + http.Redirect(w, r, "/admin", http.StatusFound) 358 + }) 359 + } 360 + 361 + func logoutHandler(app *model.AppState) http.Handler { 362 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 363 + if r.Method != http.MethodGet { 364 + http.NotFound(w, r) 365 + return 366 + } 367 + 368 + session := r.Context().Value("session").(*model.Session) 369 + err := controller.DeleteSession(app.DB, session.Token) 370 + if err != nil { 371 + fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err) 88 372 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 89 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 90 373 return 91 374 } 92 - if account == nil { 375 + 376 + http.SetCookie(w, &http.Cookie{ 377 + Name: model.COOKIE_TOKEN, 378 + Expires: time.Now(), 379 + Path: "/", 380 + }) 381 + 382 + err = logoutTemplate.Execute(w, nil) 383 + if err != nil { 384 + fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err) 385 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 386 + } 387 + }) 388 + } 389 + 390 + func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc { 391 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 392 + session := r.Context().Value("session").(*model.Session) 393 + if session.Account == nil { 93 394 // TODO: include context in redirect 94 395 http.Redirect(w, r, "/admin/login", http.StatusFound) 95 396 return 96 397 } 97 - 98 - ctx := context.WithValue(r.Context(), "account", account) 99 - 100 - next.ServeHTTP(w, r.WithContext(ctx)) 398 + next.ServeHTTP(w, r) 101 399 }) 102 400 } 103 401 ··· 121 419 http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) 122 420 }) 123 421 } 422 + 423 + func enforceSession(app *model.AppState, next http.Handler) http.Handler { 424 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 425 + sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) 426 + if err != nil && err != http.ErrNoCookie { 427 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err) 428 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 429 + return 430 + } 431 + 432 + var session *model.Session 433 + 434 + if sessionCookie != nil { 435 + // fetch existing session 436 + session, err = controller.GetSession(app.DB, sessionCookie.Value) 437 + 438 + if err != nil && !strings.Contains(err.Error(), "no rows") { 439 + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) 440 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 441 + return 442 + } 443 + 444 + if session != nil { 445 + // TODO: consider running security checks here (i.e. user agent mismatches) 446 + } 447 + } 448 + 449 + if session == nil { 450 + // create a new session 451 + session, err = controller.CreateSession(app.DB, r.UserAgent()) 452 + if err != nil { 453 + fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err) 454 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 455 + return 456 + } 457 + 458 + http.SetCookie(w, &http.Cookie{ 459 + Name: model.COOKIE_TOKEN, 460 + Value: session.Token, 461 + Expires: session.ExpiresAt, 462 + Secure: strings.HasPrefix(app.Config.BaseUrl, "https"), 463 + HttpOnly: true, 464 + Path: "/", 465 + }) 466 + } 467 + 468 + ctx := context.WithValue(r.Context(), "session", session) 469 + next.ServeHTTP(w, r.WithContext(ctx)) 470 + }) 471 + }
+20 -11
admin/releasehttp.go
··· 14 14 slices := strings.Split(r.URL.Path[1:], "/") 15 15 releaseID := slices[0] 16 16 17 - account := r.Context().Value("account").(*model.Account) 17 + session := r.Context().Value("session").(*model.Session) 18 18 19 19 release, err := controller.GetRelease(app.DB, releaseID, true) 20 20 if err != nil { ··· 56 56 } 57 57 58 58 type ReleaseResponse struct { 59 - Account *model.Account 59 + Session *model.Session 60 60 Release *model.Release 61 61 } 62 62 63 - err = pages["release"].Execute(w, ReleaseResponse{ 64 - Account: account, 63 + err = releaseTemplate.Execute(w, ReleaseResponse{ 64 + Session: session, 65 65 Release: release, 66 66 }) 67 67 if err != nil { ··· 74 74 func serveEditCredits(release *model.Release) http.Handler { 75 75 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 76 76 w.Header().Set("Content-Type", "text/html") 77 - err := components["editcredits"].Execute(w, release) 77 + err := editCreditsTemplate.Execute(w, release) 78 78 if err != nil { 79 79 fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) 80 80 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 97 97 } 98 98 99 99 w.Header().Set("Content-Type", "text/html") 100 - err = components["addcredit"].Execute(w, response{ 100 + err = addCreditTemplate.Execute(w, response{ 101 101 ReleaseID: release.ID, 102 102 Artists: artists, 103 103 }) ··· 123 123 } 124 124 125 125 w.Header().Set("Content-Type", "text/html") 126 - err = components["newcredit"].Execute(w, artist) 126 + err = newCreditTemplate.Execute(w, artist) 127 127 if err != nil { 128 128 fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) 129 129 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 134 134 func serveEditLinks(release *model.Release) http.Handler { 135 135 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 136 136 w.Header().Set("Content-Type", "text/html") 137 - err := components["editlinks"].Execute(w, release) 137 + err := editLinksTemplate.Execute(w, release) 138 138 if err != nil { 139 139 fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) 140 140 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 145 145 func serveEditTracks(release *model.Release) http.Handler { 146 146 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 147 w.Header().Set("Content-Type", "text/html") 148 - err := components["edittracks"].Execute(w, release) 148 + 149 + type editTracksData struct { 150 + Release *model.Release 151 + Add func(a int, b int) int 152 + } 153 + 154 + err := editTracksTemplate.Execute(w, editTracksData{ 155 + Release: release, 156 + Add: func(a, b int) int { return a + b }, 157 + }) 149 158 if err != nil { 150 159 fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) 151 160 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 168 177 } 169 178 170 179 w.Header().Set("Content-Type", "text/html") 171 - err = components["addtrack"].Execute(w, response{ 180 + err = addTrackTemplate.Execute(w, response{ 172 181 ReleaseID: release.ID, 173 182 Tracks: tracks, 174 183 }) ··· 195 204 } 196 205 197 206 w.Header().Set("Content-Type", "text/html") 198 - err = components["newtrack"].Execute(w, track) 207 + err = newTrackTemplate.Execute(w, track) 199 208 if err != nil { 200 209 fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) 201 210 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+60 -20
admin/static/admin.css
··· 24 24 justify-content: left; 25 25 26 26 background: #f8f8f8; 27 - border-radius: .5em; 27 + border-radius: 4px; 28 28 border: 1px solid #808080; 29 29 } 30 30 nav .icon { ··· 85 85 height: .8em; 86 86 } 87 87 88 + code { 89 + background: #303030; 90 + color: #f0f0f0; 91 + padding: .23em .3em; 92 + border-radius: 4px; 93 + } 94 + 95 + 96 + 88 97 .card { 89 98 margin-bottom: 1em; 90 99 } ··· 92 101 .card h2 { 93 102 margin: 0 0 .5em 0; 94 103 } 95 - 96 - /* 97 - .card h3, 98 - .card p { 99 - margin: 0; 100 - } 101 - */ 102 104 103 105 .card-title { 104 106 margin-bottom: 1em; ··· 127 129 128 130 129 131 132 + #message, 130 133 #error { 131 - background: #ffa9b8; 132 - border: 1px solid #dc5959; 134 + margin: 0 0 1em 0; 133 135 padding: 1em; 134 136 border-radius: 4px; 137 + background: #ffffff; 138 + border: 1px solid #888; 139 + } 140 + #message { 141 + background: #a9dfff; 142 + border-color: #599fdc; 143 + } 144 + #error { 145 + background: #ffa9b8; 146 + border-color: #dc5959; 135 147 } 136 148 137 149 138 150 151 + a.delete:not(.button) { 152 + color: #d22828; 153 + } 154 + 139 155 button, .button { 140 156 padding: .5em .8em; 141 157 font-family: inherit; 142 158 font-size: inherit; 143 - border-radius: .5em; 159 + border-radius: 4px; 144 160 border: 1px solid #a0a0a0; 145 161 background: #f0f0f0; 146 162 color: inherit; ··· 154 170 border-color: #808080; 155 171 } 156 172 157 - button { 173 + .button, button { 158 174 color: inherit; 159 175 } 160 - button.new { 176 + .button.new, button.new { 161 177 background: #c4ff6a; 162 178 border-color: #84b141; 163 179 } 164 - button.save { 180 + .button.save, button.save { 165 181 background: #6fd7ff; 166 182 border-color: #6f9eb0; 167 183 } 168 - button.delete { 184 + .button.delete, button.delete { 169 185 background: #ff7171; 170 186 border-color: #7d3535; 171 187 } 172 - button:hover { 188 + .button:hover, button:hover { 173 189 background: #fff; 174 190 border-color: #d0d0d0; 175 191 } 176 - button:active { 192 + .button:active, button:active { 177 193 background: #d0d0d0; 178 194 border-color: #808080; 179 195 } 180 - button[disabled] { 196 + .button[disabled], button[disabled] { 181 197 background: #d0d0d0 !important; 182 198 border-color: #808080 !important; 183 199 opacity: .5; 184 200 cursor: not-allowed !important; 185 201 } 186 - a.delete { 187 - color: #d22828; 202 + 203 + 204 + 205 + form { 206 + width: 100%; 207 + display: block; 208 + } 209 + form label { 210 + width: 100%; 211 + margin: 1rem 0 .5rem 0; 212 + display: block; 213 + color: #10101080; 214 + } 215 + form input { 216 + margin: .5rem 0; 217 + padding: .3rem .5rem; 218 + display: block; 219 + border-radius: 4px; 220 + border: 1px solid #808080; 221 + font-size: inherit; 222 + font-family: inherit; 223 + color: inherit; 224 + } 225 + input[disabled] { 226 + opacity: .5; 227 + cursor: not-allowed; 188 228 }
+9 -26
admin/static/edit-account.css
··· 1 1 @import url("/admin/static/index.css"); 2 2 3 - form#change-password { 4 - width: 100%; 5 - display: flex; 6 - flex-direction: column; 7 - align-items: start; 8 - } 9 - 10 - form div { 11 - width: 20rem; 12 - } 13 - 14 - form button { 15 - margin-top: 1rem; 3 + div.card { 4 + margin-bottom: 2rem; 16 5 } 17 6 18 7 label { 19 - width: 100%; 20 - margin: 1rem 0 .5rem 0; 21 - display: block; 22 - color: #10101080; 8 + width: auto; 9 + margin: 0; 10 + display: flex; 11 + align-items: center; 12 + color: inherit; 23 13 } 24 14 input { 25 - width: 100%; 15 + width: min(20rem, calc(100% - 1rem)); 26 16 margin: .5rem 0; 27 17 padding: .3rem .5rem; 28 18 display: block; ··· 33 23 color: inherit; 34 24 } 35 25 36 - #error { 37 - background: #ffa9b8; 38 - border: 1px solid #dc5959; 39 - padding: 1em; 40 - border-radius: 4px; 41 - } 42 - 43 26 .mfa-device { 44 27 padding: .75em; 45 28 background: #f8f8f8f8; 46 29 border: 1px solid #808080; 47 - border-radius: .5em; 30 + border-radius: 8px; 48 31 margin-bottom: .5em; 49 32 display: flex; 50 33 justify-content: space-between;
+1 -1
admin/static/edit-artist.css
··· 9 9 flex-direction: row; 10 10 gap: 1.2em; 11 11 12 - border-radius: .5em; 12 + border-radius: 8px; 13 13 background: #f8f8f8f8; 14 14 border: 1px solid #808080; 15 15 }
+14 -7
admin/static/edit-release.css
··· 11 11 flex-direction: row; 12 12 gap: 1.2em; 13 13 14 - border-radius: .5em; 14 + border-radius: 8px; 15 15 background: #f8f8f8f8; 16 16 border: 1px solid #808080; 17 17 } ··· 160 160 align-items: center; 161 161 gap: 1em; 162 162 163 - border-radius: .5em; 163 + border-radius: 8px; 164 164 background: #f8f8f8f8; 165 165 border: 1px solid #808080; 166 166 } ··· 170 170 } 171 171 172 172 .card.credits .credit .artist-avatar { 173 - border-radius: .5em; 173 + border-radius: 8px; 174 174 } 175 175 176 176 .card.credits .credit .artist-name { ··· 196 196 align-items: center; 197 197 gap: 1em; 198 198 199 - border-radius: .5em; 199 + border-radius: 8px; 200 200 background: #f8f8f8f8; 201 201 border: 1px solid #808080; 202 202 } ··· 215 215 } 216 216 217 217 #editcredits .credit .artist-avatar { 218 - border-radius: .5em; 218 + border-radius: 8px; 219 219 } 220 220 221 221 #editcredits .credit .credit-info { ··· 228 228 } 229 229 230 230 #editcredits .credit .credit-info .credit-attribute label { 231 + width: auto; 232 + margin: 0; 231 233 display: flex; 232 234 align-items: center; 233 235 } 234 236 235 237 #editcredits .credit .credit-info .credit-attribute input[type="text"] { 236 - margin-left: .25em; 238 + margin: 0 0 0 .25em; 237 239 padding: .2em .4em; 238 240 flex-grow: 1; 239 241 font-family: inherit; 240 242 border: 1px solid #8888; 241 243 border-radius: 4px; 242 244 color: inherit; 245 + } 246 + #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { 247 + margin: 0 .3em; 243 248 } 244 249 245 250 #editcredits .credit .artist-name { ··· 369 374 #editlinks td input[type="text"] { 370 375 width: calc(100% - .6em); 371 376 height: 100%; 377 + margin: 0; 372 378 padding: 0 .3em; 373 379 border: none; 380 + border-radius: 0; 374 381 outline: none; 375 382 cursor: pointer; 376 383 background: none; ··· 393 400 flex-direction: column; 394 401 gap: .5em; 395 402 396 - border-radius: .5em; 403 + border-radius: 8px; 397 404 background: #f8f8f8f8; 398 405 border: 1px solid #808080; 399 406 }
+1 -1
admin/static/edit-track.css
··· 11 11 flex-direction: row; 12 12 gap: 1.2em; 13 13 14 - border-radius: .5em; 14 + border-radius: 8px; 15 15 background: #f8f8f8f8; 16 16 border: 1px solid #808080; 17 17 }
+2 -20
admin/static/index.css
··· 1 1 @import url("/admin/static/release-list-item.css"); 2 2 3 - .create-btn { 4 - background: #c4ff6a; 5 - padding: .5em .8em; 6 - border-radius: .5em; 7 - border: 1px solid #84b141; 8 - text-decoration: none; 9 - } 10 - .create-btn:hover { 11 - background: #fff; 12 - border-color: #d0d0d0; 13 - text-decoration: inherit; 14 - } 15 - .create-btn:active { 16 - background: #d0d0d0; 17 - border-color: #808080; 18 - text-decoration: inherit; 19 - } 20 - 21 3 .artist { 22 4 margin-bottom: .5em; 23 5 padding: .5em; ··· 26 8 align-items: center; 27 9 gap: .5em; 28 10 29 - border-radius: .5em; 11 + border-radius: 8px; 30 12 background: #f8f8f8f8; 31 13 border: 1px solid #808080; 32 14 } ··· 49 31 flex-direction: column; 50 32 gap: .5em; 51 33 52 - border-radius: .5em; 34 + border-radius: 8px; 53 35 background: #f8f8f8f8; 54 36 border: 1px solid #808080; 55 37 }
+3 -3
admin/static/release-list-item.css
··· 5 5 flex-direction: row; 6 6 gap: 1em; 7 7 8 - border-radius: .5em; 8 + border-radius: 8px; 9 9 background: #f8f8f8f8; 10 10 border: 1px solid #808080; 11 11 } ··· 50 50 padding: .5em; 51 51 display: block; 52 52 53 - border-radius: .5em; 53 + border-radius: 8px; 54 54 text-decoration: none; 55 55 color: #f0f0f0; 56 56 background: #303030; ··· 73 73 padding: .3em .5em; 74 74 display: inline-block; 75 75 76 - border-radius: .3em; 76 + border-radius: 4px; 77 77 background: #e0e0e0; 78 78 79 79 transition: color .1s, background .1s;
+80 -55
admin/templates.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "html/template" 5 - "path/filepath" 4 + "html/template" 5 + "path/filepath" 6 6 ) 7 7 8 - var pages = map[string]*template.Template{ 9 - "index": template.Must(template.ParseFiles( 10 - filepath.Join("admin", "views", "layout.html"), 11 - filepath.Join("views", "prideflag.html"), 12 - filepath.Join("admin", "components", "release", "release-list-item.html"), 13 - filepath.Join("admin", "views", "index.html"), 14 - )), 8 + var indexTemplate = template.Must(template.ParseFiles( 9 + filepath.Join("admin", "views", "layout.html"), 10 + filepath.Join("views", "prideflag.html"), 11 + filepath.Join("admin", "components", "release", "release-list-item.html"), 12 + filepath.Join("admin", "views", "index.html"), 13 + )) 15 14 16 - "login": template.Must(template.ParseFiles( 17 - filepath.Join("admin", "views", "layout.html"), 18 - filepath.Join("views", "prideflag.html"), 19 - filepath.Join("admin", "views", "login.html"), 20 - )), 21 - "create-account": template.Must(template.ParseFiles( 22 - filepath.Join("admin", "views", "layout.html"), 23 - filepath.Join("views", "prideflag.html"), 24 - filepath.Join("admin", "views", "create-account.html"), 25 - )), 26 - "logout": template.Must(template.ParseFiles( 27 - filepath.Join("admin", "views", "layout.html"), 28 - filepath.Join("views", "prideflag.html"), 29 - filepath.Join("admin", "views", "logout.html"), 30 - )), 31 - "account": template.Must(template.ParseFiles( 32 - filepath.Join("admin", "views", "layout.html"), 33 - filepath.Join("views", "prideflag.html"), 34 - filepath.Join("admin", "views", "edit-account.html"), 35 - )), 15 + var loginTemplate = template.Must(template.ParseFiles( 16 + filepath.Join("admin", "views", "layout.html"), 17 + filepath.Join("views", "prideflag.html"), 18 + filepath.Join("admin", "views", "login.html"), 19 + )) 20 + var loginTOTPTemplate = template.Must(template.ParseFiles( 21 + filepath.Join("admin", "views", "layout.html"), 22 + filepath.Join("views", "prideflag.html"), 23 + filepath.Join("admin", "views", "login-totp.html"), 24 + )) 25 + var registerTemplate = template.Must(template.ParseFiles( 26 + filepath.Join("admin", "views", "layout.html"), 27 + filepath.Join("views", "prideflag.html"), 28 + filepath.Join("admin", "views", "register.html"), 29 + )) 30 + var logoutTemplate = template.Must(template.ParseFiles( 31 + filepath.Join("admin", "views", "layout.html"), 32 + filepath.Join("views", "prideflag.html"), 33 + filepath.Join("admin", "views", "logout.html"), 34 + )) 35 + var accountTemplate = template.Must(template.ParseFiles( 36 + filepath.Join("admin", "views", "layout.html"), 37 + filepath.Join("views", "prideflag.html"), 38 + filepath.Join("admin", "views", "edit-account.html"), 39 + )) 40 + var totpSetupTemplate = template.Must(template.ParseFiles( 41 + filepath.Join("admin", "views", "layout.html"), 42 + filepath.Join("views", "prideflag.html"), 43 + filepath.Join("admin", "views", "totp-setup.html"), 44 + )) 45 + var totpConfirmTemplate = template.Must(template.ParseFiles( 46 + filepath.Join("admin", "views", "layout.html"), 47 + filepath.Join("views", "prideflag.html"), 48 + filepath.Join("admin", "views", "totp-confirm.html"), 49 + )) 36 50 37 - "release": template.Must(template.ParseFiles( 38 - filepath.Join("admin", "views", "layout.html"), 39 - filepath.Join("views", "prideflag.html"), 40 - filepath.Join("admin", "views", "edit-release.html"), 41 - )), 42 - "artist": template.Must(template.ParseFiles( 43 - filepath.Join("admin", "views", "layout.html"), 44 - filepath.Join("views", "prideflag.html"), 45 - filepath.Join("admin", "views", "edit-artist.html"), 46 - )), 47 - "track": template.Must(template.ParseFiles( 48 - filepath.Join("admin", "views", "layout.html"), 49 - filepath.Join("views", "prideflag.html"), 50 - filepath.Join("admin", "components", "release", "release-list-item.html"), 51 - filepath.Join("admin", "views", "edit-track.html"), 52 - )), 53 - } 51 + var releaseTemplate = template.Must(template.ParseFiles( 52 + filepath.Join("admin", "views", "layout.html"), 53 + filepath.Join("views", "prideflag.html"), 54 + filepath.Join("admin", "views", "edit-release.html"), 55 + )) 56 + var artistTemplate = template.Must(template.ParseFiles( 57 + filepath.Join("admin", "views", "layout.html"), 58 + filepath.Join("views", "prideflag.html"), 59 + filepath.Join("admin", "views", "edit-artist.html"), 60 + )) 61 + var trackTemplate = template.Must(template.ParseFiles( 62 + filepath.Join("admin", "views", "layout.html"), 63 + filepath.Join("views", "prideflag.html"), 64 + filepath.Join("admin", "components", "release", "release-list-item.html"), 65 + filepath.Join("admin", "views", "edit-track.html"), 66 + )) 54 67 55 - var components = map[string]*template.Template{ 56 - "editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))), 57 - "addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))), 58 - "newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))), 68 + var editCreditsTemplate = template.Must(template.ParseFiles( 69 + filepath.Join("admin", "components", "credits", "editcredits.html"), 70 + )) 71 + var addCreditTemplate = template.Must(template.ParseFiles( 72 + filepath.Join("admin", "components", "credits", "addcredit.html"), 73 + )) 74 + var newCreditTemplate = template.Must(template.ParseFiles( 75 + filepath.Join("admin", "components", "credits", "newcredit.html"), 76 + )) 59 77 60 - "editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))), 78 + var editLinksTemplate = template.Must(template.ParseFiles( 79 + filepath.Join("admin", "components", "links", "editlinks.html"), 80 + )) 61 81 62 - "edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))), 63 - "addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))), 64 - "newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))), 65 - } 82 + var editTracksTemplate = template.Must(template.ParseFiles( 83 + filepath.Join("admin", "components", "tracks", "edittracks.html"), 84 + )) 85 + var addTrackTemplate = template.Must(template.ParseFiles( 86 + filepath.Join("admin", "components", "tracks", "addtrack.html"), 87 + )) 88 + var newTrackTemplate = template.Must(template.ParseFiles( 89 + filepath.Join("admin", "components", "tracks", "newtrack.html"), 90 + ))
+4 -4
admin/trackhttp.go
··· 32 32 } 33 33 34 34 type TrackResponse struct { 35 - Account *model.Account 35 + Session *model.Session 36 36 Track *model.Track 37 37 Releases []*model.Release 38 38 } 39 39 40 - account := r.Context().Value("account").(*model.Account) 40 + session := r.Context().Value("session").(*model.Session) 41 41 42 - err = pages["track"].Execute(w, TrackResponse{ 43 - Account: account, 42 + err = trackTemplate.Execute(w, TrackResponse{ 43 + Session: session, 44 44 Track: track, 45 45 Releases: releases, 46 46 })
-73
admin/views/create-account.html
··· 1 - {{define "head"}} 2 - <title>Register - ari melody 💫</title> 3 - <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 - <style> 6 - p a { 7 - color: #2a67c8; 8 - } 9 - 10 - a.discord { 11 - color: #5865F2; 12 - } 13 - 14 - form { 15 - width: 100%; 16 - display: flex; 17 - flex-direction: column; 18 - align-items: center; 19 - } 20 - 21 - form div { 22 - width: 20rem; 23 - } 24 - 25 - form button { 26 - margin-top: 1rem; 27 - } 28 - 29 - label { 30 - width: 100%; 31 - margin: 1rem 0 .5rem 0; 32 - display: block; 33 - color: #10101080; 34 - } 35 - input { 36 - width: 100%; 37 - margin: .5rem 0; 38 - padding: .3rem .5rem; 39 - display: block; 40 - border-radius: 4px; 41 - border: 1px solid #808080; 42 - font-size: inherit; 43 - font-family: inherit; 44 - color: inherit; 45 - } 46 - </style> 47 - {{end}} 48 - 49 - {{define "content"}} 50 - <main> 51 - {{if .Message}} 52 - <p id="error">{{.Message}}</p> 53 - {{end}} 54 - 55 - <form action="/admin/register" method="POST" id="create-account"> 56 - <div> 57 - <label for="username">Username</label> 58 - <input type="text" name="username" value=""> 59 - 60 - <label for="email">Email</label> 61 - <input type="text" name="email" value=""> 62 - 63 - <label for="password">Password</label> 64 - <input type="password" name="password" value=""> 65 - 66 - <label for="invite">Invite Code</label> 67 - <input type="text" name="invite" value=""> 68 - </div> 69 - 70 - <button type="submit" class="new">Create Account</button> 71 - </form> 72 - </main> 73 - {{end}}
+31 -16
admin/views/edit-account.html
··· 6 6 7 7 {{define "content"}} 8 8 <main> 9 - <h1>Account Settings ({{.Account.Username}})</h1> 9 + {{if .Session.Message.Valid}} 10 + <p id="message">{{html .Session.Message.String}}</p> 11 + {{end}} 12 + {{if .Session.Error.Valid}} 13 + <p id="error">{{html .Session.Error.String}}</p> 14 + {{end}} 15 + <h1>Account Settings ({{.Session.Account.Username}})</h1> 10 16 11 17 <div class="card-title"> 12 18 <h2>Change Password</h2> 13 19 </div> 14 20 <div class="card"> 15 - <form action="/api/v1/change-password" method="POST" id="change-password"> 16 - <div> 17 - <label for="current-password">Current Password</label> 18 - <input type="password" name="current-password" value="" autocomplete="current-password"> 21 + <form action="/admin/account/password" method="POST" id="change-password"> 22 + <label for="current-password">Current Password</label> 23 + <input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required> 19 24 20 - <label for="new-password">Password</label> 21 - <input type="password" name="new-password" value="" autocomplete="new-password"> 25 + <label for="new-password">New Password</label> 26 + <input type="password" id="new-password" name="new-password" value="" autocomplete="new-password" required> 22 27 23 - <label for="confirm-password">Confirm Password</label> 24 - <input type="password" name="confirm-password" value="" autocomplete="new-password"> 25 - </div> 28 + <label for="confirm-password">Confirm Password</label> 29 + <input type="password" id="confirm-password" value="" autocomplete="new-password" required> 26 30 27 31 <button type="submit" class="save">Change Password</button> 28 32 </form> ··· 36 40 {{range .TOTPs}} 37 41 <div class="mfa-device"> 38 42 <div> 39 - <p class="mfa-device-name">{{.Name}}</p> 40 - <p class="mfa-device-date">Added: {{.CreatedAt}}</p> 43 + <p class="mfa-device-name">{{.TOTP.Name}}</p> 44 + <p class="mfa-device-date">Added: {{.CreatedAtString}}</p> 41 45 </div> 42 46 <div> 43 - <a class="delete">Delete</a> 47 + <a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a> 44 48 </div> 45 49 </div> 46 50 {{end}} ··· 48 52 <p>You have no MFA devices.</p> 49 53 {{end}} 50 54 51 - <button type="submit" class="new" id="add-mfa-device">Add MFA Device</button> 55 + <div> 56 + <button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button> 57 + <a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a> 58 + </div> 52 59 </div> 53 60 54 61 <div class="card-title"> ··· 58 65 <p> 59 66 Clicking the button below will delete your account. 60 67 This action is <strong>irreversible</strong>. 61 - You will be prompted to confirm this decision. 68 + You will need to enter your password and TOTP below. 62 69 </p> 63 - <button class="delete" id="delete">Delete Account</button> 70 + <form action="/admin/account/delete" method="POST"> 71 + <label for="password">Password</label> 72 + <input type="password" name="password" value="" autocomplete="current-password" required> 73 + 74 + <label for="totp">TOTP</label> 75 + <input type="text" name="totp" value="" autocomplete="one-time-code" required> 76 + 77 + <button type="submit" class="delete">Delete Account</button> 78 + </form> 64 79 </div> 65 80 66 81 </main>
+5 -5
admin/views/edit-artist.html
··· 36 36 {{if .Credits}} 37 37 {{range .Credits}} 38 38 <div class="credit"> 39 - <img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> 39 + <img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> 40 40 <div class="credit-info"> 41 - <h3 class="credit-name"><a href="/admin/release/{{.Artist.Release.ID}}">{{.Artist.Release.Title}}</a></h3> 42 - <p class="credit-artists">{{.Artist.Release.PrintArtists true true}}</p> 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> 43 43 <p class="artist-role"> 44 - Role: {{.Artist.Role}} 45 - {{if .Artist.Primary}} 44 + Role: {{.Role}} 45 + {{if .Primary}} 46 46 <small>(Primary)</small> 47 47 {{end}} 48 48 </p>
+3 -3
admin/views/index.html
··· 9 9 10 10 <div class="card-title"> 11 11 <h1>Releases</h1> 12 - <a class="create-btn" id="create-release">Create New</a> 12 + <a class="button new" id="create-release">Create New</a> 13 13 </div> 14 14 <div class="card releases"> 15 15 {{range .Releases}} ··· 22 22 23 23 <div class="card-title"> 24 24 <h1>Artists</h1> 25 - <a class="create-btn" id="create-artist">Create New</a> 25 + <a class="button new" id="create-artist">Create New</a> 26 26 </div> 27 27 <div class="card artists"> 28 28 {{range $Artist := .Artists}} ··· 38 38 39 39 <div class="card-title"> 40 40 <h1>Tracks</h1> 41 - <a class="create-btn" id="create-track">Create New</a> 41 + <a class="button new" id="create-track">Create New</a> 42 42 </div> 43 43 <div class="card tracks"> 44 44 <p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
+5 -2
admin/views/layout.html
··· 24 24 <a href="/admin">home</a> 25 25 </div> 26 26 <div class="flex-fill"></div> 27 - {{if .Account}} 27 + {{if .Session.Account}} 28 + <div class="nav-item"> 29 + <a href="/admin/account">account ({{.Session.Account.Username}})</a> 30 + </div> 28 31 <div class="nav-item"> 29 - <a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a> 32 + <a href="/admin/logout" id="logout">log out</a> 30 33 </div> 31 34 {{else}} 32 35 <div class="nav-item">
+42
admin/views/login-totp.html
··· 1 + {{define "head"}} 2 + <title>Login - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/admin.css"> 5 + <style> 6 + form#login-totp { 7 + width: 100%; 8 + display: flex; 9 + flex-direction: column; 10 + align-items: center; 11 + } 12 + 13 + form div { 14 + width: 20rem; 15 + } 16 + 17 + form button { 18 + margin-top: 1rem; 19 + } 20 + 21 + input { 22 + width: calc(100% - 1rem - 2px); 23 + } 24 + </style> 25 + {{end}} 26 + 27 + {{define "content"}} 28 + <main> 29 + <form action="/admin/login" method="POST" id="login-totp"> 30 + <h1>Two-Factor Authentication</h1> 31 + 32 + <div> 33 + <label for="totp">TOTP</label> 34 + <input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus> 35 + <input type="hidden" name="username" value="{{.Username}}"> 36 + <input type="hidden" name="password" value="{{.Password}}"> 37 + </div> 38 + 39 + <button type="submit" class="save">Login</button> 40 + </form> 41 + </main> 42 + {{end}}
+11 -47
admin/views/login.html
··· 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 4 <link rel="stylesheet" href="/admin/static/admin.css"> 5 5 <style> 6 - p a { 7 - color: #2a67c8; 8 - } 9 - 10 - a.discord { 11 - color: #5865F2; 12 - } 13 - 14 - form { 6 + form#login { 15 7 width: 100%; 16 8 display: flex; 17 9 flex-direction: column; ··· 26 18 margin-top: 1rem; 27 19 } 28 20 29 - label { 30 - width: 100%; 31 - margin: 1rem 0 .5rem 0; 32 - display: block; 33 - color: #10101080; 34 - } 35 21 input { 36 - width: 100%; 37 - margin: .5rem 0; 38 - padding: .3rem .5rem; 39 - display: block; 40 - border-radius: 4px; 41 - border: 1px solid #808080; 42 - font-size: inherit; 43 - font-family: inherit; 44 - color: inherit; 45 - } 46 - input[disabled] { 47 - opacity: .5; 48 - cursor: not-allowed; 22 + width: calc(100% - 1rem - 2px); 49 23 } 50 24 </style> 51 25 {{end}} 52 26 53 27 {{define "content"}} 54 28 <main> 55 - {{if .Message}} 56 - <p id="error">{{.Message}}</p> 29 + {{if .Session.Message.Valid}} 30 + <p id="message">{{html .Session.Message.String}}</p> 31 + {{end}} 32 + {{if .Session.Error.Valid}} 33 + <p id="error">{{html .Session.Error.String}}</p> 57 34 {{end}} 58 35 59 - {{if .Token}} 36 + <form action="/admin/login" method="POST" id="login"> 37 + <h1>Log In</h1> 60 38 61 - <meta http-equiv="refresh" content="0;url=/admin/" /> 62 - <p> 63 - Logged in successfully. 64 - You should be redirected to <a href="/admin">/admin</a> soon. 65 - </p> 66 - 67 - {{else}} 68 - 69 - <form action="/admin/login" method="POST" id="login"> 70 39 <div> 71 40 <label for="username">Username</label> 72 - <input type="text" name="username" value="" autocomplete="username"> 41 + <input type="text" name="username" value="" autocomplete="username" required autofocus> 73 42 74 43 <label for="password">Password</label> 75 - <input type="password" name="password" value="" autocomplete="current-password"> 76 - 77 - <label for="totp">TOTP</label> 78 - <input type="text" name="totp" value="" autocomplete="one-time-code"> 44 + <input type="password" name="password" value="" autocomplete="current-password" required> 79 45 </div> 80 46 81 47 <button type="submit" class="save">Login</button> 82 48 </form> 83 - 84 - {{end}} 85 49 </main> 86 50 {{end}}
+2 -5
admin/views/logout.html
··· 12 12 {{define "content"}} 13 13 <main> 14 14 15 - <meta http-equiv="refresh" content="5;url=/" /> 15 + <meta http-equiv="refresh" content="0;url=/admin/login" /> 16 16 <p> 17 17 Logged out successfully. 18 - You should be redirected to <a href="/">/</a> in 5 seconds. 19 - <script> 20 - localStorage.removeItem("arime-token"); 21 - </script> 18 + You should be redirected to <a href="/admin/login">/admin/login</a> shortly. 22 19 </p> 23 20 24 21 </main>
+61
admin/views/register.html
··· 1 + {{define "head"}} 2 + <title>Register - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/admin.css"> 5 + <style> 6 + p a { 7 + color: #2a67c8; 8 + } 9 + 10 + a.discord { 11 + color: #5865F2; 12 + } 13 + 14 + form#register { 15 + width: 100%; 16 + display: flex; 17 + flex-direction: column; 18 + align-items: center; 19 + } 20 + 21 + form div { 22 + width: 20rem; 23 + } 24 + 25 + form button { 26 + margin-top: 1rem; 27 + } 28 + 29 + input { 30 + width: calc(100% - 1rem - 2px); 31 + } 32 + </style> 33 + {{end}} 34 + 35 + {{define "content"}} 36 + <main> 37 + {{if .Session.Error.Valid}} 38 + <p id="error">{{html .Session.Error.String}}</p> 39 + {{end}} 40 + 41 + <form action="/admin/register" method="POST" id="register"> 42 + <h1>Create Account</h1> 43 + 44 + <div> 45 + <label for="username">Username</label> 46 + <input type="text" name="username" value="" autocomplete="username" required autofocus> 47 + 48 + <label for="email">Email</label> 49 + <input type="text" name="email" value="" autocomplete="email" required> 50 + 51 + <label for="password">Password</label> 52 + <input type="password" name="password" value="" autocomplete="new-password" required> 53 + 54 + <label for="invite">Invite Code</label> 55 + <input type="text" name="invite" value="" autocomplete="off" required> 56 + </div> 57 + 58 + <button type="submit" class="new">Create Account</button> 59 + </form> 60 + </main> 61 + {{end}}
+41
admin/views/totp-confirm.html
··· 1 + {{define "head"}} 2 + <title>TOTP Confirmation - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/admin.css"> 5 + <style> 6 + .qr-code { 7 + border: 1px solid #8888; 8 + } 9 + code { 10 + user-select: all; 11 + } 12 + </style> 13 + {{end}} 14 + 15 + {{define "content"}} 16 + <main> 17 + {{if .Session.Error.Valid}} 18 + <p id="error">{{html .Session.Error.String}}</p> 19 + {{end}} 20 + 21 + <form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup"> 22 + <img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code"> 23 + 24 + <p> 25 + Scan the QR code above into your authentication app or password manager, 26 + then enter your 2FA code below. 27 + </p> 28 + 29 + <p> 30 + If the QR code does not work, you may also enter this secret code: 31 + </p> 32 + 33 + <p><code>{{.TOTP.Secret}}</code></p> 34 + 35 + <label for="totp">TOTP:</label> 36 + <input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus> 37 + 38 + <button type="submit" class="new">Create</button> 39 + </form> 40 + </main> 41 + {{end}}
+20
admin/views/totp-setup.html
··· 1 + {{define "head"}} 2 + <title>TOTP Setup - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/admin.css"> 5 + {{end}} 6 + 7 + {{define "content"}} 8 + <main> 9 + {{if .Session.Error.Valid}} 10 + <p id="error">{{html .Session.Error.String}}</p> 11 + {{end}} 12 + 13 + <form action="/admin/account/totp-setup" method="POST" id="totp-setup"> 14 + <label for="totp-name">TOTP Device Name:</label> 15 + <input type="text" name="totp-name" value="" autocomplete="off" required autofocus> 16 + 17 + <button type="submit" class="new">Create</button> 18 + </form> 19 + </main> 20 + {{end}}
+62 -12
api/api.go
··· 1 1 package api 2 2 3 3 import ( 4 + "context" 5 + "errors" 4 6 "fmt" 5 7 "net/http" 8 + "os" 6 9 "strings" 7 10 8 - "arimelody-web/admin" 9 11 "arimelody-web/controller" 10 12 "arimelody-web/model" 11 13 ) ··· 36 38 ServeArtist(app, artist).ServeHTTP(w, r) 37 39 case http.MethodPut: 38 40 // PUT /api/v1/artist/{id} (admin) 39 - admin.RequireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) 41 + requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r) 40 42 case http.MethodDelete: 41 43 // DELETE /api/v1/artist/{id} (admin) 42 - admin.RequireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) 44 + requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r) 43 45 default: 44 46 http.NotFound(w, r) 45 47 } ··· 51 53 ServeAllArtists(app).ServeHTTP(w, r) 52 54 case http.MethodPost: 53 55 // POST /api/v1/artist (admin) 54 - admin.RequireAccount(app, CreateArtist(app)).ServeHTTP(w, r) 56 + requireAccount(app, CreateArtist(app)).ServeHTTP(w, r) 55 57 default: 56 58 http.NotFound(w, r) 57 59 } ··· 78 80 ServeRelease(app, release).ServeHTTP(w, r) 79 81 case http.MethodPut: 80 82 // PUT /api/v1/music/{id} (admin) 81 - admin.RequireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) 83 + requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r) 82 84 case http.MethodDelete: 83 85 // DELETE /api/v1/music/{id} (admin) 84 - admin.RequireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) 86 + requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r) 85 87 default: 86 88 http.NotFound(w, r) 87 89 } ··· 93 95 ServeCatalog(app).ServeHTTP(w, r) 94 96 case http.MethodPost: 95 97 // POST /api/v1/music (admin) 96 - admin.RequireAccount(app, CreateRelease(app)).ServeHTTP(w, r) 98 + requireAccount(app, CreateRelease(app)).ServeHTTP(w, r) 97 99 default: 98 100 http.NotFound(w, r) 99 101 } ··· 117 119 switch r.Method { 118 120 case http.MethodGet: 119 121 // GET /api/v1/track/{id} (admin) 120 - admin.RequireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) 122 + requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r) 121 123 case http.MethodPut: 122 124 // PUT /api/v1/track/{id} (admin) 123 - admin.RequireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) 125 + requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r) 124 126 case http.MethodDelete: 125 127 // DELETE /api/v1/track/{id} (admin) 126 - admin.RequireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) 128 + requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r) 127 129 default: 128 130 http.NotFound(w, r) 129 131 } ··· 132 134 switch r.Method { 133 135 case http.MethodGet: 134 136 // GET /api/v1/track (admin) 135 - admin.RequireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) 137 + requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r) 136 138 case http.MethodPost: 137 139 // POST /api/v1/track (admin) 138 - admin.RequireAccount(app, CreateTrack(app)).ServeHTTP(w, r) 140 + requireAccount(app, CreateTrack(app)).ServeHTTP(w, r) 139 141 default: 140 142 http.NotFound(w, r) 141 143 } ··· 143 145 144 146 return mux 145 147 } 148 + 149 + func requireAccount(app *model.AppState, next http.Handler) http.Handler { 150 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 + session, err := getSession(app, r) 152 + if err != nil { 153 + fmt.Fprintf(os.Stderr, "WARN: Failed to get session: %v\n", err) 154 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 155 + return 156 + } 157 + if session.Account == nil { 158 + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 159 + return 160 + } 161 + ctx := context.WithValue(r.Context(), "session", session) 162 + next.ServeHTTP(w, r.WithContext(ctx)) 163 + }) 164 + } 165 + 166 + func getSession(app *model.AppState, r *http.Request) (*model.Session, error) { 167 + var token string 168 + 169 + // check cookies first 170 + sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) 171 + if err != nil && err != http.ErrNoCookie { 172 + return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err)) 173 + } 174 + if sessionCookie != nil { 175 + token = sessionCookie.Value 176 + } else { 177 + // check Authorization header 178 + token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 179 + } 180 + 181 + if token == "" { return nil, nil } 182 + 183 + // fetch existing session 184 + session, err := controller.GetSession(app.DB, token) 185 + 186 + if err != nil && !strings.Contains(err.Error(), "no rows") { 187 + return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err)) 188 + } 189 + 190 + if session != nil { 191 + // TODO: consider running security checks here (i.e. user agent mismatches) 192 + } 193 + 194 + return session, nil 195 + }
+2 -7
api/artist.go
··· 51 51 } 52 52 ) 53 53 54 - account, err := controller.GetAccountByRequest(app.DB, r) 55 - if err != nil { 56 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 57 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 58 - return 59 - } 60 - show_hidden_releases := account != nil 54 + session := r.Context().Value("session").(*model.Session) 55 + show_hidden_releases := session != nil && session.Account != nil 61 56 62 57 dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) 63 58 if err != nil {
+4 -14
api/release.go
··· 19 19 // only allow authorised users to view hidden releases 20 20 privileged := false 21 21 if !release.Visible { 22 - account, err := controller.GetAccountByRequest(app.DB, r) 23 - if err != nil { 24 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 25 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 26 - return 27 - } 28 - if account != nil { 22 + session := r.Context().Value("session").(*model.Session) 23 + if session != nil && session.Account != nil { 29 24 // TODO: check privilege on release 30 25 privileged = true 31 26 } ··· 145 140 } 146 141 147 142 catalog := []Release{} 148 - account, err := controller.GetAccountByRequest(app.DB, r) 149 - if err != nil { 150 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 151 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 152 - return 153 - } 143 + session := r.Context().Value("session").(*model.Session) 154 144 for _, release := range releases { 155 145 if !release.Visible { 156 146 privileged := false 157 - if account != nil { 147 + if session != nil && session.Account != nil { 158 148 // TODO: check privilege on release 159 149 privileged = true 160 150 }
+22 -33
controller/account.go
··· 2 2 3 3 import ( 4 4 "arimelody-web/model" 5 - "errors" 6 - "fmt" 7 5 "net/http" 8 6 "strings" 9 7 ··· 21 19 return accounts, nil 22 20 } 23 21 24 - func GetAccount(db *sqlx.DB, username string) (*model.Account, error) { 22 + func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) { 23 + var account = model.Account{} 24 + 25 + err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id) 26 + if err != nil { 27 + if strings.Contains(err.Error(), "no rows") { 28 + return nil, nil 29 + } 30 + return nil, err 31 + } 32 + 33 + return &account, nil 34 + } 35 + 36 + func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) { 25 37 var account = model.Account{} 26 38 27 39 err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) ··· 49 61 return &account, nil 50 62 } 51 63 52 - func GetAccountByToken(db *sqlx.DB, token string) (*model.Account, error) { 53 - if token == "" { return nil, nil } 64 + func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) { 65 + if sessionToken == "" { return nil, nil } 54 66 55 67 account := model.Account{} 56 68 57 - err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", token) 69 + err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken) 58 70 if err != nil { 59 71 if strings.Contains(err.Error(), "no rows") { 60 72 return nil, nil ··· 65 77 return &account, nil 66 78 } 67 79 68 - func GetTokenFromRequest(db *sqlx.DB, r *http.Request) string { 80 + func GetSessionFromRequest(db *sqlx.DB, r *http.Request) string { 69 81 tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 70 82 if len(tokenStr) > 0 { 71 83 return tokenStr ··· 78 90 return cookie.Value 79 91 } 80 92 81 - func GetAccountByRequest(db *sqlx.DB, r *http.Request) (*model.Account, error) { 82 - tokenStr := GetTokenFromRequest(db, r) 83 - 84 - token, err := GetToken(db, tokenStr) 85 - if err != nil { 86 - if strings.Contains(err.Error(), "no rows") { 87 - return nil, nil 88 - } 89 - return nil, errors.New("GetToken: " + err.Error()) 90 - } 91 - 92 - // does user-agent match the token? 93 - if r.UserAgent() != token.UserAgent { 94 - // invalidate the token 95 - DeleteToken(db, tokenStr) 96 - fmt.Printf("WARN: Attempted use of token by unauthorised User-Agent (Expected `%s`, got `%s`)\n", token.UserAgent, r.UserAgent()) 97 - // TODO: log unauthorised activity to the user 98 - return nil, errors.New("User agent mismatch") 99 - } 100 - 101 - return GetAccountByToken(db, tokenStr) 102 - } 103 - 104 93 func CreateAccount(db *sqlx.DB, account *model.Account) error { 105 94 err := db.Get( 106 95 &account.ID, ··· 119 108 func UpdateAccount(db *sqlx.DB, account *model.Account) error { 120 109 _, err := db.Exec( 121 110 "UPDATE account " + 122 - "SET username=$2, password=$3, email=$4, avatar_url=$5) " + 111 + "SET username=$2,password=$3,email=$4,avatar_url=$5 " + 123 112 "WHERE id=$1", 124 113 account.ID, 125 114 account.Username, ··· 131 120 return err 132 121 } 133 122 134 - func DeleteAccount(db *sqlx.DB, username string) error { 135 - _, err := db.Exec("DELETE FROM account WHERE username=$1", username) 123 + func DeleteAccount(db *sqlx.DB, accountID string) error { 124 + _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) 136 125 return err 137 126 }
+2
controller/config.go
··· 19 19 20 20 config := model.Config{ 21 21 BaseUrl: "https://arimelody.me", 22 + Host: "0.0.0.0", 22 23 Port: 8080, 23 24 DB: model.DBConfig{ 24 25 Host: "127.0.0.1", ··· 55 56 var err error 56 57 57 58 if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } 59 + if env, has := os.LookupEnv("ARIMELODY_HOST"); has { config.Host = env } 58 60 if env, has := os.LookupEnv("ARIMELODY_PORT"); has { 59 61 config.Port, err = strconv.ParseInt(env, 10, 0) 60 62 if err != nil { return errors.New("ARIMELODY_PORT: " + err.Error()) }
+120
controller/qr.go
··· 1 + package controller 2 + 3 + import ( 4 + "bytes" 5 + "encoding/base64" 6 + "errors" 7 + "fmt" 8 + "image" 9 + "image/color" 10 + "image/png" 11 + 12 + "github.com/skip2/go-qrcode" 13 + ) 14 + 15 + func GenerateQRCode(data string) (string, error) { 16 + imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256) 17 + if err != nil { 18 + return "", err 19 + } 20 + base64Img := base64.StdEncoding.EncodeToString(imgBytes) 21 + return base64Img, nil 22 + } 23 + 24 + // vvv DEPRECATED vvv 25 + 26 + const margin = 4 27 + 28 + type QRCodeECCLevel int64 29 + const ( 30 + LOW QRCodeECCLevel = iota 31 + MEDIUM 32 + QUARTILE 33 + HIGH 34 + ) 35 + 36 + func noDepsGenerateQRCode() (string, error) { 37 + version := 1 38 + 39 + size := 0 40 + size = 21 + version * 4 41 + if version > 10 { 42 + return "", errors.New(fmt.Sprintf("QR version %d not supported", version)) 43 + } 44 + 45 + img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2)) 46 + 47 + // fill white 48 + for y := range size + margin * 2 { 49 + for x := range size + margin * 2 { 50 + img.Set(x, y, color.White) 51 + } 52 + } 53 + 54 + // draw alignment squares 55 + drawLargeAlignmentSquare(margin, margin, img) 56 + drawLargeAlignmentSquare(margin, margin + size - 7, img) 57 + drawLargeAlignmentSquare(margin + size - 7, margin, img) 58 + drawSmallAlignmentSquare(size - 5, size - 5, img) 59 + /* 60 + if version > 4 { 61 + space := version * 3 - 2 62 + end := size / space 63 + for y := range size / space + 1 { 64 + for x := range size / space + 1 { 65 + if x == 0 && y == 0 { continue } 66 + if x == 0 && y == end { continue } 67 + if x == end && y == 0 { continue } 68 + if x == end && y == end { continue } 69 + drawSmallAlignmentSquare( 70 + x * space + margin + 4, 71 + y * space + margin + 4, 72 + img, 73 + ) 74 + } 75 + } 76 + } 77 + */ 78 + 79 + // draw timing bits 80 + for i := margin + 6; i < size - 4; i++ { 81 + if (i % 2 == 0) { 82 + img.Set(i, margin + 6, color.Black) 83 + img.Set(margin + 6, i, color.Black) 84 + } 85 + } 86 + img.Set(margin + 8, size - 4, color.Black) 87 + 88 + var imgBuf bytes.Buffer 89 + err := png.Encode(&imgBuf, img) 90 + if err != nil { 91 + return "", err 92 + } 93 + 94 + base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes()) 95 + 96 + return "data:image/png;base64," + base64Img, nil 97 + } 98 + 99 + func drawLargeAlignmentSquare(x int, y int, img *image.Gray) { 100 + for yi := range 7 { 101 + for xi := range 7 { 102 + if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) { 103 + img.Set(x + xi, y + yi, color.Black) 104 + } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) { 105 + img.Set(x + xi, y + yi, color.Black) 106 + } 107 + } 108 + } 109 + } 110 + 111 + func drawSmallAlignmentSquare(x int, y int, img *image.Gray) { 112 + for yi := range 5 { 113 + for xi := range 5 { 114 + if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) { 115 + img.Set(x + xi, y + yi, color.Black) 116 + } 117 + } 118 + } 119 + img.Set(x + 2, y + 2, color.Black) 120 + }
+130
controller/session.go
··· 1 + package controller 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + 7 + "arimelody-web/model" 8 + 9 + "github.com/jmoiron/sqlx" 10 + ) 11 + 12 + const TOKEN_LEN = 64 13 + 14 + func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) { 15 + tokenString := GenerateAlnumString(TOKEN_LEN) 16 + 17 + session := model.Session{ 18 + Token: string(tokenString), 19 + UserAgent: userAgent, 20 + CreatedAt: time.Now(), 21 + ExpiresAt: time.Now().Add(time.Hour * 24), 22 + } 23 + 24 + _, err := db.Exec("INSERT INTO session " + 25 + "(token, user_agent, created_at, expires_at) VALUES " + 26 + "($1, $2, $3, $4)", 27 + session.Token, 28 + session.UserAgent, 29 + session.CreatedAt, 30 + session.ExpiresAt, 31 + ) 32 + if err != nil { 33 + return nil, err 34 + } 35 + 36 + return &session, nil 37 + } 38 + 39 + // func WriteSession(db *sqlx.DB, session *model.Session) error { 40 + // _, err := db.Exec( 41 + // "UPDATE session " + 42 + // "SET account=$2,message=$3,error=$4 " + 43 + // "WHERE token=$1", 44 + // session.Token, 45 + // session.Account.ID, 46 + // session.Message, 47 + // session.Error, 48 + // ) 49 + // return err 50 + // } 51 + 52 + func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error { 53 + var err error 54 + session.Account = account 55 + if account == nil { 56 + _, err = db.Exec("UPDATE session SET account=NULL WHERE token=$1", session.Token) 57 + } else { 58 + _, err = db.Exec("UPDATE session SET account=$2 WHERE token=$1", session.Token, account.ID) 59 + } 60 + return err 61 + } 62 + 63 + func SetSessionMessage(db *sqlx.DB, session *model.Session, message string) error { 64 + var err error 65 + if message == "" { 66 + if !session.Message.Valid { return nil } 67 + session.Message = sql.NullString{ } 68 + _, err = db.Exec("UPDATE session SET message=NULL WHERE token=$1", session.Token) 69 + } else { 70 + session.Message = sql.NullString{ String: message, Valid: true } 71 + _, err = db.Exec("UPDATE session SET message=$2 WHERE token=$1", session.Token, message) 72 + } 73 + return err 74 + } 75 + 76 + func SetSessionError(db *sqlx.DB, session *model.Session, message string) error { 77 + var err error 78 + if message == "" { 79 + if !session.Error.Valid { return nil } 80 + session.Error = sql.NullString{ } 81 + _, err = db.Exec("UPDATE session SET error=NULL WHERE token=$1", session.Token) 82 + } else { 83 + session.Error = sql.NullString{ String: message, Valid: true } 84 + _, err = db.Exec("UPDATE session SET error=$2 WHERE token=$1", session.Token, message) 85 + } 86 + return err 87 + } 88 + 89 + func GetSession(db *sqlx.DB, token string) (*model.Session, error) { 90 + type dbSession struct { 91 + model.Session 92 + AccountID sql.NullString `db:"account"` 93 + } 94 + 95 + session := dbSession{} 96 + err := db.Get( 97 + &session, 98 + "SELECT * FROM session WHERE token=$1", 99 + token, 100 + ) 101 + if err != nil { 102 + return nil, err 103 + } 104 + 105 + if session.AccountID.Valid { 106 + session.Account, err = GetAccountByID(db, session.AccountID.String) 107 + if err != nil { 108 + return nil, err 109 + } 110 + } 111 + 112 + return &session.Session, err 113 + } 114 + 115 + // func GetAllSessionsForAccount(db *sqlx.DB, accountID string) ([]model.Session, error) { 116 + // sessions := []model.Session{} 117 + // err := db.Select(&sessions, "SELECT * FROM session WHERE account=$1 AND expires_at>current_timestamp", accountID) 118 + // return sessions, err 119 + // } 120 + 121 + func DeleteAllSessionsForAccount(db *sqlx.DB, accountID string) error { 122 + _, err := db.Exec("DELETE FROM session WHERE account=$1", accountID) 123 + return err 124 + } 125 + 126 + func DeleteSession(db *sqlx.DB, token string) error { 127 + _, err := db.Exec("DELETE FROM session WHERE token=$1", token) 128 + return err 129 + } 130 +
-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 -
+31 -9
controller/totp.go
··· 18 18 ) 19 19 20 20 const TOTP_SECRET_LENGTH = 32 21 - const TIME_STEP int64 = 30 22 - const CODE_LENGTH = 6 21 + const TOTP_TIME_STEP int64 = 30 22 + const TOTP_CODE_LENGTH = 6 23 23 24 24 func GenerateTOTP(secret string, timeStepOffset int) string { 25 25 decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) ··· 27 27 fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n") 28 28 } 29 29 30 - counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) 30 + counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset) 31 31 counterBytes := make([]byte, 8) 32 32 binary.BigEndian.PutUint64(counterBytes, uint64(counter)) 33 33 ··· 37 37 38 38 offset := hash[len(hash) - 1] & 0x0f 39 39 binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) 40 - code := binaryCode % int32(math.Pow10(CODE_LENGTH)) 40 + code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH)) 41 41 42 - return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) 42 + return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code) 43 43 } 44 44 45 45 func GenerateTOTPSecret(length int) string { ··· 64 64 query := url.Query() 65 65 query.Set("secret", secret) 66 66 query.Set("issuer", "arimelody.me") 67 - query.Set("algorithm", "SHA1") 68 - query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH)) 69 - query.Set("period", fmt.Sprintf("%d", TIME_STEP)) 67 + // query.Set("algorithm", "SHA1") 68 + // query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH)) 69 + // query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP)) 70 70 url.RawQuery = query.Encode() 71 71 72 72 return url.String() ··· 89 89 return totps, nil 90 90 } 91 91 92 + func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) { 93 + totps, err := GetTOTPsForAccount(db, accountID) 94 + if err != nil { 95 + return nil, err 96 + } 97 + for _, method := range totps { 98 + check := GenerateTOTP(method.Secret, 0) 99 + if check == totp { 100 + return &method, nil 101 + } 102 + // try again with offset- maybe user input the code late? 103 + check = GenerateTOTP(method.Secret, 1) 104 + if check == totp { 105 + return &method, nil 106 + } 107 + } 108 + // user failed all TOTP checks 109 + // note: this state will still occur even if the account has no TOTP methods. 110 + return nil, nil 111 + } 112 + 92 113 func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) { 93 114 totp := model.TOTP{} 94 115 95 116 err := db.Get( 96 117 &totp, 97 118 "SELECT * FROM totp " + 98 - "WHERE account=$1", 119 + "WHERE account=$1 AND name=$2", 99 120 accountID, 121 + name, 100 122 ) 101 123 if err != nil { 102 124 if strings.Contains(err.Error(), "no rows") {
+5 -1
go.mod
··· 8 8 ) 9 9 10 10 require golang.org/x/crypto v0.27.0 // indirect 11 - require github.com/pelletier/go-toml/v2 v2.2.3 // indirect 11 + 12 + require ( 13 + github.com/pelletier/go-toml/v2 v2.2.3 // indirect 14 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect 15 + )
+4 -2
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 11 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 14 12 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 13 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 14 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 15 + golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= 16 + golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+76 -31
main.go
··· 4 4 "errors" 5 5 "fmt" 6 6 "log" 7 + "math" 7 8 "math/rand" 8 9 "net/http" 9 10 "os" ··· 22 23 23 24 "github.com/jmoiron/sqlx" 24 25 _ "github.com/lib/pq" 26 + "golang.org/x/crypto/bcrypt" 25 27 ) 26 28 27 29 // used for database migrations ··· 87 89 } 88 90 username := os.Args[2] 89 91 totpName := os.Args[3] 90 - secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) 91 92 92 - account, err := controller.GetAccount(app.DB, username) 93 + account, err := controller.GetAccountByUsername(app.DB, username) 93 94 if err != nil { 94 - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) 95 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 95 96 os.Exit(1) 96 97 } 97 98 98 99 if account == nil { 99 - fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) 100 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 100 101 os.Exit(1) 101 102 } 102 103 104 + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) 103 105 totp := model.TOTP { 104 106 AccountID: account.ID, 105 107 Name: totpName, ··· 108 110 109 111 err = controller.CreateTOTP(app.DB, &totp) 110 112 if err != nil { 111 - fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) 113 + if strings.HasPrefix(err.Error(), "pq: duplicate key") { 114 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name) 115 + os.Exit(1) 116 + } 117 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) 112 118 os.Exit(1) 113 119 } 114 120 ··· 124 130 username := os.Args[2] 125 131 totpName := os.Args[3] 126 132 127 - account, err := controller.GetAccount(app.DB, username) 133 + account, err := controller.GetAccountByUsername(app.DB, username) 128 134 if err != nil { 129 - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) 135 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 130 136 os.Exit(1) 131 137 } 132 138 133 139 if account == nil { 134 - fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) 140 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 135 141 os.Exit(1) 136 142 } 137 143 138 144 err = controller.DeleteTOTP(app.DB, account.ID, totpName) 139 145 if err != nil { 140 - fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) 146 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) 141 147 os.Exit(1) 142 148 } 143 149 ··· 151 157 } 152 158 username := os.Args[2] 153 159 154 - account, err := controller.GetAccount(app.DB, username) 160 + account, err := controller.GetAccountByUsername(app.DB, username) 155 161 if err != nil { 156 - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) 162 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 157 163 os.Exit(1) 158 164 } 159 165 160 166 if account == nil { 161 - fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) 167 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 162 168 os.Exit(1) 163 169 } 164 170 165 171 totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) 166 172 if err != nil { 167 - fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err) 173 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err) 168 174 os.Exit(1) 169 175 } 170 176 ··· 184 190 username := os.Args[2] 185 191 totpName := os.Args[3] 186 192 187 - account, err := controller.GetAccount(app.DB, username) 193 + account, err := controller.GetAccountByUsername(app.DB, username) 188 194 if err != nil { 189 - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) 195 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 190 196 os.Exit(1) 191 197 } 192 198 193 199 if account == nil { 194 - fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) 200 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 195 201 os.Exit(1) 196 202 } 197 203 198 204 totp, err := controller.GetTOTP(app.DB, account.ID, totpName) 199 205 if err != nil { 200 - fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err) 206 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) 201 207 os.Exit(1) 202 208 } 203 209 204 210 if totp == nil { 205 - fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) 211 + fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) 206 212 os.Exit(1) 207 213 } 208 214 ··· 214 220 fmt.Printf("Creating invite...\n") 215 221 invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) 216 222 if err != nil { 217 - fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) 223 + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) 218 224 os.Exit(1) 219 225 } 220 226 221 - fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code) 227 + fmt.Printf( 228 + "Here you go! This code expires in %d hours: %s\n", 229 + int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())), 230 + invite.Code, 231 + ) 222 232 return 223 233 224 234 case "purgeInvites": 225 235 fmt.Printf("Deleting all invites...\n") 226 236 err := controller.DeleteAllInvites(app.DB) 227 237 if err != nil { 228 - fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) 238 + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err) 229 239 os.Exit(1) 230 240 } 231 241 ··· 235 245 case "listAccounts": 236 246 accounts, err := controller.GetAllAccounts(app.DB) 237 247 if err != nil { 238 - fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err) 248 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err) 239 249 os.Exit(1) 240 250 } 241 251 242 252 for _, account := range accounts { 253 + email := "<none>" 254 + if account.Email.Valid { email = account.Email.String } 243 255 fmt.Printf( 244 256 "User: %s\n" + 245 257 "\tID: %s\n" + ··· 247 259 "\tCreated: %s\n", 248 260 account.Username, 249 261 account.ID, 250 - account.Email, 262 + email, 251 263 account.CreatedAt, 252 264 ) 253 265 } 254 266 return 255 267 268 + case "changePassword": 269 + if len(os.Args) < 4 { 270 + fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n") 271 + os.Exit(1) 272 + } 273 + 274 + username := os.Args[2] 275 + password := os.Args[3] 276 + account, err := controller.GetAccountByUsername(app.DB, username) 277 + if err != nil { 278 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 279 + os.Exit(1) 280 + } 281 + if account == nil { 282 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 283 + os.Exit(1) 284 + } 285 + 286 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 287 + if err != nil { 288 + fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err) 289 + os.Exit(1) 290 + } 291 + account.Password = string(hashedPassword) 292 + err = controller.UpdateAccount(app.DB, account) 293 + if err != nil { 294 + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) 295 + os.Exit(1) 296 + } 297 + 298 + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) 299 + return 300 + 256 301 case "deleteAccount": 257 302 if len(os.Args) < 3 { 258 303 fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") ··· 261 306 username := os.Args[2] 262 307 fmt.Printf("Deleting account \"%s\"...\n", username) 263 308 264 - account, err := controller.GetAccount(app.DB, username) 309 + account, err := controller.GetAccountByUsername(app.DB, username) 265 310 if err != nil { 266 - fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) 311 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 267 312 os.Exit(1) 268 313 } 269 314 270 315 if account == nil { 271 - fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) 316 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 272 317 os.Exit(1) 273 318 } 274 319 ··· 279 324 return 280 325 } 281 326 282 - err = controller.DeleteAccount(app.DB, username) 327 + err = controller.DeleteAccount(app.DB, account.ID) 283 328 if err != nil { 284 - fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) 329 + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) 285 330 os.Exit(1) 286 331 } 287 332 ··· 338 383 339 384 // start the web server! 340 385 mux := createServeMux(&app) 341 - fmt.Printf("Now serving at %s:%d\n", app.Config.BaseUrl, app.Config.Port) 386 + fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) 342 387 log.Fatal( 343 - http.ListenAndServe(fmt.Sprintf(":%d", app.Config.Port), 388 + http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port), 344 389 HTTPLog(DefaultHeaders(mux)), 345 390 )) 346 391 } ··· 359 404 } 360 405 361 406 if r.URL.Path == "/" || r.URL.Path == "/index.html" { 362 - err := templates.Pages["index"].Execute(w, nil) 407 + err := templates.IndexTemplate.Execute(w, nil) 363 408 if err != nil { 364 409 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 365 410 }
+11 -8
model/account.go
··· 1 1 package model 2 2 3 - import "time" 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 4 7 5 - const COOKIE_TOKEN string = "AM_TOKEN" 8 + const COOKIE_TOKEN string = "AM_SESSION" 6 9 7 10 type ( 8 11 Account struct { 9 - ID string `json:"id" db:"id"` 10 - Username string `json:"username" db:"username"` 11 - Password string `json:"password" db:"password"` 12 - Email string `json:"email" db:"email"` 13 - AvatarURL string `json:"avatar_url" db:"avatar_url"` 14 - CreatedAt time.Time `json:"created_at" db:"created_at"` 12 + ID string `json:"id" db:"id"` 13 + Username string `json:"username" db:"username"` 14 + Password string `json:"password" db:"password"` 15 + Email sql.NullString `json:"email" db:"email"` 16 + AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` 17 + CreatedAt time.Time `json:"created_at" db:"created_at"` 15 18 16 19 Privileges []AccountPrivilege `json:"privileges"` 17 20 }
+1
model/appstate.go
··· 19 19 20 20 Config struct { 21 21 BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` 22 + Host string `toml:"host"` 22 23 Port int64 `toml:"port"` 23 24 DataDirectory string `toml:"data_dir"` 24 25 DB DBConfig `toml:"db"`
+17
model/session.go
··· 1 + package model 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + type Session struct { 9 + Token string `json:"token" db:"token"` 10 + UserAgent string `json:"user_agent" db:"user_agent"` 11 + CreatedAt time.Time `json:"created_at" db:"created_at"` 12 + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` 13 + 14 + Account *Account `json:"-" db:"account"` 15 + Message sql.NullString `json:"-" db:"message"` 16 + Error sql.NullString `json:"-" db:"error"` 17 + }
-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 - }
+2
model/track.go
··· 12 12 Description string `json:"description"` 13 13 Lyrics string `json:"lyrics" db:"lyrics"` 14 14 PreviewURL string `json:"previewURL" db:"preview_url"` 15 + 16 + Number int 15 17 } 16 18 ) 17 19
+5 -5
public/style/footer.css
··· 1 1 footer { 2 - border-top: 1px solid #888; 2 + border-top: 1px solid #8888; 3 3 } 4 4 5 5 #footer { 6 - width: min(calc(100% - 4rem), 720px); 7 - margin: auto; 8 - padding: 2rem 0; 9 - color: #aaa; 6 + width: min(calc(100% - 4rem), 720px); 7 + margin: auto; 8 + padding: 2rem 0; 9 + color: #aaa; 10 10 } 11 11
+1 -1
public/style/index.css
··· 91 91 text-align: center; 92 92 line-height: 0px; 93 93 border-width: 1px 0 0 0; 94 - border-color: #888f; 94 + border-color: #888; 95 95 margin: 1.5em 0; 96 96 overflow: visible; 97 97 }
+15 -15
schema_migration/000-init.sql
··· 1 - CREATE SCHEMA IF NOT EXISTS arimelody; 2 - 3 1 -- 4 2 -- Tables 5 3 -- 6 4 7 5 -- Accounts 8 6 CREATE TABLE arimelody.account ( 9 - id uuid DEFAULT gen_random_uuid(), 10 - username text NOT NULL UNIQUE, 11 - password text NOT NULL, 12 - email text, 13 - avatar_url text, 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, 14 12 created_at TIMESTAMP DEFAULT current_timestamp 15 13 ); 16 14 ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); 17 15 18 16 -- Privilege 19 17 CREATE TABLE arimelody.privilege ( 20 - account uuid NOT NULL, 21 - privilege text NOT NULL 18 + account UUID NOT NULL, 19 + privilege TEXT NOT NULL 22 20 ); 23 21 ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); 24 22 ··· 30 28 ); 31 29 ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); 32 30 33 - -- Tokens 34 - CREATE TABLE arimelody.token ( 31 + -- Session 32 + CREATE TABLE arimelody.session ( 35 33 token TEXT, 36 - account UUID NOT NULL, 37 34 user_agent TEXT NOT NULL, 38 35 created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, 39 - expires_at TIMESTAMP DEFAULT NULL 36 + expires_at TIMESTAMP DEFAULT NULL, 37 + account UUID, 38 + message TEXT, 39 + error TEXT 40 40 ); 41 - ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); 41 + ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token); 42 42 43 43 -- TOTPs 44 44 CREATE TABLE arimelody.totp ( ··· 118 118 -- 119 119 120 120 ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 121 - ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 121 + ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 122 122 ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; 123 123 124 124 ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
+15 -27
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 1 -- 16 2 -- New items 17 3 -- 18 4 19 - -- Acounts 5 + -- Accounts 20 6 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, 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, 26 12 created_at TIMESTAMP DEFAULT current_timestamp 27 13 ); 28 14 ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); 29 15 30 16 -- Privilege 31 17 CREATE TABLE arimelody.privilege ( 32 - account uuid NOT NULL, 33 - privilege text NOT NULL 18 + account UUID NOT NULL, 19 + privilege TEXT NOT NULL 34 20 ); 35 21 ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); 36 22 ··· 42 28 ); 43 29 ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code); 44 30 45 - -- Tokens 46 - CREATE TABLE arimelody.token ( 31 + -- Session 32 + CREATE TABLE arimelody.session ( 47 33 token TEXT, 48 - account UUID NOT NULL, 49 34 user_agent TEXT NOT NULL, 50 35 created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, 51 - expires_at TIMESTAMP DEFAULT NULL 36 + expires_at TIMESTAMP DEFAULT NULL, 37 + account UUID, 38 + message TEXT, 39 + error TEXT 52 40 ); 53 - ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); 41 + ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token); 54 42 55 43 -- TOTPs 56 44 CREATE TABLE arimelody.totp (
+21 -26
templates/templates.go
··· 5 5 "path/filepath" 6 6 ) 7 7 8 - var Pages = map[string]*template.Template{ 9 - "index": template.Must(template.ParseFiles( 10 - filepath.Join("views", "layout.html"), 11 - filepath.Join("views", "header.html"), 12 - filepath.Join("views", "footer.html"), 13 - filepath.Join("views", "prideflag.html"), 14 - filepath.Join("views", "index.html"), 15 - )), 16 - "music": template.Must(template.ParseFiles( 17 - filepath.Join("views", "layout.html"), 18 - filepath.Join("views", "header.html"), 19 - filepath.Join("views", "footer.html"), 20 - filepath.Join("views", "prideflag.html"), 21 - filepath.Join("views", "music.html"), 22 - )), 23 - "music-gateway": template.Must(template.ParseFiles( 24 - filepath.Join("views", "layout.html"), 25 - filepath.Join("views", "header.html"), 26 - filepath.Join("views", "footer.html"), 27 - filepath.Join("views", "prideflag.html"), 28 - filepath.Join("views", "music-gateway.html"), 29 - )), 30 - } 31 - 32 - var Components = map[string]*template.Template{ 33 - } 8 + var IndexTemplate = template.Must(template.ParseFiles( 9 + filepath.Join("views", "layout.html"), 10 + filepath.Join("views", "header.html"), 11 + filepath.Join("views", "footer.html"), 12 + filepath.Join("views", "prideflag.html"), 13 + filepath.Join("views", "index.html"), 14 + )) 15 + var MusicTemplate = template.Must(template.ParseFiles( 16 + filepath.Join("views", "layout.html"), 17 + filepath.Join("views", "header.html"), 18 + filepath.Join("views", "footer.html"), 19 + filepath.Join("views", "prideflag.html"), 20 + filepath.Join("views", "music.html"), 21 + )) 22 + var MusicGatewayTemplate = template.Must(template.ParseFiles( 23 + filepath.Join("views", "layout.html"), 24 + filepath.Join("views", "header.html"), 25 + filepath.Join("views", "footer.html"), 26 + filepath.Join("views", "prideflag.html"), 27 + filepath.Join("views", "music-gateway.html"), 28 + ))
+4 -10
view/music.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net/http" 6 - "os" 7 6 8 7 "arimelody-web/controller" 9 8 "arimelody-web/model" ··· 48 47 } 49 48 } 50 49 51 - err = templates.Pages["music"].Execute(w, releases) 50 + err = templates.MusicTemplate.Execute(w, releases) 52 51 if err != nil { 53 52 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 54 53 } ··· 60 59 // only allow authorised users to view hidden releases 61 60 privileged := false 62 61 if !release.Visible { 63 - account, err := controller.GetAccountByRequest(app.DB, r) 64 - if err != nil { 65 - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) 66 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 67 - return 68 - } 69 - if account != nil { 62 + session := r.Context().Value("session").(*model.Session) 63 + if session != nil && session.Account != nil { 70 64 // TODO: check privilege on release 71 65 privileged = true 72 66 } ··· 85 79 response.Links = release.Links 86 80 } 87 81 88 - err := templates.Pages["music-gateway"].Execute(w, response) 82 + err := templates.MusicGatewayTemplate.Execute(w, response) 89 83 90 84 if err != nil { 91 85 fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)