2024-04-16 21:58:39 +00:00
package admin
import (
2024-07-31 03:09:22 +00:00
"context"
2025-01-23 00:37:19 +00:00
"database/sql"
2024-04-16 21:58:39 +00:00
"fmt"
"net/http"
2024-07-31 03:09:22 +00:00
"os"
2024-08-01 23:53:19 +00:00
"path/filepath"
2025-01-23 00:37:19 +00:00
"strings"
"time"
2024-07-31 03:09:22 +00:00
2025-01-20 10:34:39 +00:00
"arimelody-web/controller"
2025-02-07 16:40:58 +00:00
"arimelody-web/log"
2025-01-20 10:34:39 +00:00
"arimelody-web/model"
2025-01-23 00:37:19 +00:00
"golang.org/x/crypto/bcrypt"
2024-04-16 21:58:39 +00:00
)
2025-01-21 14:53:18 +00:00
func Handler ( app * model . AppState ) http . Handler {
2024-07-31 12:45:34 +00:00
mux := http . NewServeMux ( )
2024-07-31 03:09:22 +00:00
2025-01-26 00:48:19 +00:00
mux . Handle ( "/qr-test" , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2025-01-26 20:09:18 +00:00
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" )
2025-01-26 00:48:19 +00:00
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to generate QR code: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
w . Write ( [ ] byte ( "<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>" ) )
} ) )
2025-01-23 00:37:19 +00:00
mux . Handle ( "/login" , loginHandler ( app ) )
2025-01-26 23:41:35 +00:00
mux . Handle ( "/totp" , loginTOTPHandler ( app ) )
mux . Handle ( "/logout" , requireAccount ( logoutHandler ( app ) ) )
2025-01-23 00:37:19 +00:00
mux . Handle ( "/register" , registerAccountHandler ( app ) )
2025-01-26 23:41:35 +00:00
mux . Handle ( "/account" , requireAccount ( accountIndexHandler ( app ) ) )
mux . Handle ( "/account/" , requireAccount ( http . StripPrefix ( "/account" , accountHandler ( app ) ) ) )
2025-01-23 00:37:19 +00:00
2025-02-07 16:40:58 +00:00
mux . Handle ( "/logs" , requireAccount ( logsHandler ( app ) ) )
2025-01-26 23:41:35 +00:00
mux . Handle ( "/release/" , requireAccount ( http . StripPrefix ( "/release" , serveRelease ( app ) ) ) )
mux . Handle ( "/artist/" , requireAccount ( http . StripPrefix ( "/artist" , serveArtist ( app ) ) ) )
mux . Handle ( "/track/" , requireAccount ( http . StripPrefix ( "/track" , serveTrack ( app ) ) ) )
2025-01-23 00:37:19 +00:00
mux . Handle ( "/static/" , http . StripPrefix ( "/static" , staticHandler ( ) ) )
2025-01-26 23:41:35 +00:00
mux . Handle ( "/" , requireAccount ( AdminIndexHandler ( app ) ) )
2025-01-23 00:37:19 +00:00
// response wrapper to make sure a session cookie exists
return enforceSession ( app , mux )
}
func AdminIndexHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2024-08-02 21:48:26 +00:00
if r . URL . Path != "/" {
http . NotFound ( w , r )
return
}
2025-01-23 00:37:19 +00:00
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
2024-08-02 21:48:26 +00:00
2025-01-21 14:53:18 +00:00
releases , err := controller . GetAllReleases ( app . DB , false , 0 , true )
2024-09-01 03:43:32 +00:00
if err != nil {
2025-01-20 15:08:01 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to pull releases: %s\n" , err )
2024-09-01 03:43:32 +00:00
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
2025-01-21 14:53:18 +00:00
artists , err := controller . GetAllArtists ( app . DB )
2024-09-01 03:43:32 +00:00
if err != nil {
2025-01-20 15:08:01 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to pull artists: %s\n" , err )
2024-09-01 03:43:32 +00:00
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
2025-01-21 14:53:18 +00:00
tracks , err := controller . GetOrphanTracks ( app . DB )
2025-01-23 00:37:19 +00:00
if err != nil {
2025-01-20 15:08:01 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to pull orphan tracks: %s\n" , err )
2025-01-23 00:37:19 +00:00
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
2024-09-01 03:43:32 +00:00
2024-09-03 07:07:45 +00:00
type IndexData struct {
2025-01-23 00:37:19 +00:00
Session * model . Session
2025-01-20 10:34:39 +00:00
Releases [ ] * model . Release
Artists [ ] * model . Artist
Tracks [ ] * model . Track
2024-08-03 22:24:15 +00:00
}
2025-01-26 20:09:18 +00:00
err = indexTemplate . Execute ( w , IndexData {
2025-01-23 00:37:19 +00:00
Session : session ,
2024-09-01 03:43:32 +00:00
Releases : releases ,
Artists : artists ,
2024-08-03 23:23:55 +00:00
Tracks : tracks ,
2024-08-31 14:25:44 +00:00
} )
2025-01-23 00:37:19 +00:00
if err != nil {
2025-01-20 15:08:01 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to render admin index: %s\n" , err )
2025-01-23 00:37:19 +00:00
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
} )
}
func registerAccountHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
if session . Account != nil {
// user is already logged in
http . Redirect ( w , r , "/admin" , http . StatusFound )
return
}
2025-01-23 09:39:40 +00:00
type registerData struct {
Session * model . Session
}
2025-01-23 00:37:19 +00:00
render := func ( ) {
2025-01-26 20:09:18 +00:00
err := registerTemplate . Execute ( w , registerData { Session : session } )
2025-01-23 00:37:19 +00:00
if err != nil {
fmt . Printf ( "WARN: Error rendering create account page: %s\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
}
}
if r . Method == http . MethodGet {
render ( )
return
}
if r . Method != http . MethodPost {
http . NotFound ( w , r )
return
}
err := r . ParseForm ( )
if err != nil {
2025-01-23 12:09:33 +00:00
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
2025-01-23 00:37:19 +00:00
return
}
type RegisterRequest struct {
Username string ` json:"username" `
Email string ` json:"email" `
Password string ` json:"password" `
Invite string ` json:"invite" `
}
credentials := RegisterRequest {
Username : r . Form . Get ( "username" ) ,
Email : r . Form . Get ( "email" ) ,
Password : r . Form . Get ( "password" ) ,
Invite : r . Form . Get ( "invite" ) ,
}
// make sure invite code exists in DB
invite , err := controller . GetInvite ( app . DB , credentials . Invite )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to retrieve invite: %v\n" , err )
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
if invite == nil || time . Now ( ) . After ( invite . ExpiresAt ) {
if invite != nil {
err := controller . DeleteInvite ( app . DB , invite . Code )
if err != nil { fmt . Fprintf ( os . Stderr , "WARN: Failed to delete expired invite: %v\n" , err ) }
}
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Invalid invite code." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
hashedPassword , err := bcrypt . GenerateFromPassword ( [ ] byte ( credentials . Password ) , bcrypt . DefaultCost )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to generate password hash: %v\n" , err )
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
account := model . Account {
Username : credentials . Username ,
Password : string ( hashedPassword ) ,
Email : sql . NullString { String : credentials . Email , Valid : true } ,
AvatarURL : sql . NullString { String : "/img/default-avatar.png" , Valid : true } ,
}
err = controller . CreateAccount ( app . DB , & account )
if err != nil {
if strings . HasPrefix ( err . Error ( ) , "pq: duplicate key" ) {
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "An account with that username already exists." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
fmt . Fprintf ( os . Stderr , "WARN: Failed to create account: %v\n" , err )
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
2024-07-31 12:45:34 +00:00
2025-02-07 16:40:58 +00:00
app . Log . Info ( log . TYPE_ACCOUNT , "Account \"%s\" (%s) created using invite \"%s\". (%s)" , account . Username , account . ID , invite . Code , controller . ResolveIP ( r ) )
2025-01-23 09:39:40 +00:00
2025-01-23 00:37:19 +00:00
err = controller . DeleteInvite ( app . DB , invite . Code )
2025-02-07 16:40:58 +00:00
if err != nil {
app . Log . Warn ( log . TYPE_ACCOUNT , "Failed to delete expired invite \"%s\": %v" , invite . Code , err )
}
2025-01-23 00:37:19 +00:00
// registration success!
controller . SetSessionAccount ( app . DB , session , & account )
2025-01-23 12:09:33 +00:00
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
2025-01-23 00:37:19 +00:00
http . Redirect ( w , r , "/admin" , http . StatusFound )
} )
2024-07-31 03:09:22 +00:00
}
2025-01-23 00:37:19 +00:00
func loginHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if r . Method != http . MethodGet && r . Method != http . MethodPost {
http . NotFound ( w , r )
return
}
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
type loginData struct {
Session * model . Session
}
render := func ( ) {
2025-01-26 20:09:18 +00:00
err := loginTemplate . Execute ( w , loginData { Session : session } )
2025-01-23 00:37:19 +00:00
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Error rendering admin login page: %s\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
}
if r . Method == http . MethodGet {
if session . Account != nil {
// user is already logged in
http . Redirect ( w , r , "/admin" , http . StatusFound )
return
}
render ( )
return
}
err := r . ParseForm ( )
if err != nil {
2025-01-23 12:09:33 +00:00
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
2025-01-23 00:37:19 +00:00
return
}
2025-01-26 23:41:35 +00:00
if ! r . Form . Has ( "username" ) || ! r . Form . Has ( "password" ) {
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
return
2025-01-23 00:37:19 +00:00
}
2025-01-26 23:41:35 +00:00
username := r . FormValue ( "username" )
password := r . FormValue ( "password" )
account , err := controller . GetAccountByUsername ( app . DB , username )
2025-01-23 00:37:19 +00:00
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to fetch account for login: %v\n" , err )
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Invalid username or password." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
if account == nil {
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Invalid username or password." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
2025-01-26 23:41:35 +00:00
err = bcrypt . CompareHashAndPassword ( [ ] byte ( account . Password ) , [ ] byte ( password ) )
2025-01-23 00:37:19 +00:00
if err != nil {
2025-02-07 16:40:58 +00:00
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" attempted login with incorrect password. (%s)" , account . Username , controller . ResolveIP ( r ) )
2025-01-23 09:39:40 +00:00
controller . SetSessionError ( app . DB , session , "Invalid username or password." )
2025-01-23 00:37:19 +00:00
render ( )
return
}
2025-01-26 23:41:35 +00:00
totps , err := controller . GetTOTPsForAccount ( app . DB , account . ID )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to fetch TOTPs: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
if len ( totps ) > 0 {
err = controller . SetSessionAttemptAccount ( app . DB , session , account )
2025-01-23 13:53:06 +00:00
if err != nil {
2025-01-26 23:41:35 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to set attempt session: %v\n" , err )
2025-01-23 13:53:06 +00:00
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
2025-01-26 23:41:35 +00:00
http . Redirect ( w , r , "/admin/totp" , http . StatusFound )
return
}
2025-01-23 13:53:06 +00:00
2025-02-07 16:40:58 +00:00
// login success!
2025-01-26 23:41:35 +00:00
// TODO: log login activity to user
2025-02-07 16:40:58 +00:00
app . Log . Info ( log . TYPE_ACCOUNT , "\"%s\" logged in. (%s)" , account . Username , controller . ResolveIP ( r ) )
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" does not have any TOTP methods assigned." , account . Username )
2025-01-26 23:41:35 +00:00
err = controller . SetSessionAccount ( app . DB , session , account )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to set session account: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
http . Redirect ( w , r , "/admin" , http . StatusFound )
} )
}
func loginTOTPHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
if session . AttemptAccount == nil {
http . Error ( w , http . StatusText ( http . StatusUnauthorized ) , http . StatusUnauthorized )
return
}
type loginTOTPData struct {
Session * model . Session
}
render := func ( ) {
err := loginTOTPTemplate . Execute ( w , loginTOTPData { Session : session } )
2025-01-23 13:53:06 +00:00
if err != nil {
2025-01-26 23:41:35 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to render login TOTP page: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
2025-01-23 13:53:06 +00:00
return
}
2025-01-23 00:37:19 +00:00
}
2025-01-23 13:53:06 +00:00
2025-01-26 23:41:35 +00:00
if r . Method == http . MethodGet {
render ( )
return
2025-01-23 00:37:19 +00:00
}
2025-01-26 23:41:35 +00:00
if r . Method != http . MethodPost {
http . NotFound ( w , r )
return
}
2025-01-23 00:37:19 +00:00
2025-01-26 23:41:35 +00:00
r . ParseForm ( )
if ! r . Form . Has ( "totp" ) {
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
return
}
totpCode := r . FormValue ( "totp" )
if len ( totpCode ) != controller . TOTP_CODE_LENGTH {
2025-02-07 16:40:58 +00:00
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" failed login (Invalid TOTP). (%s)" , session . AttemptAccount . Username , controller . ResolveIP ( r ) )
2025-01-26 23:41:35 +00:00
controller . SetSessionError ( app . DB , session , "Invalid TOTP." )
render ( )
return
}
totpMethod , err := controller . CheckTOTPForAccount ( app . DB , session . AttemptAccount . ID , totpCode )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to check TOTPs: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
if totpMethod == nil {
2025-02-07 16:40:58 +00:00
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" failed login (Invalid TOTP). (%s)" , session . AttemptAccount . Username , controller . ResolveIP ( r ) )
2025-01-26 23:41:35 +00:00
controller . SetSessionError ( app . DB , session , "Invalid TOTP." )
render ( )
return
}
2025-02-07 16:40:58 +00:00
app . Log . Info ( log . TYPE_ACCOUNT , "\"%s\" logged in with TOTP method \"%s\". (%s)" , session . AttemptAccount . Username , totpMethod . Name , controller . ResolveIP ( r ) )
2025-01-26 23:41:35 +00:00
err = controller . SetSessionAccount ( app . DB , session , session . AttemptAccount )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to set session account: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
err = controller . SetSessionAttemptAccount ( app . DB , session , nil )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to clear attempt session: %v\n" , err )
}
2025-01-23 12:09:33 +00:00
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
2025-01-23 00:37:19 +00:00
http . Redirect ( w , r , "/admin" , http . StatusFound )
} )
}
func logoutHandler ( app * model . AppState ) http . Handler {
2024-07-31 03:09:22 +00:00
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2025-01-23 00:37:19 +00:00
if r . Method != http . MethodGet {
http . NotFound ( w , r )
return
}
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
err := controller . DeleteSession ( app . DB , session . Token )
2024-08-02 21:48:26 +00:00
if err != nil {
2025-01-23 00:37:19 +00:00
fmt . Fprintf ( os . Stderr , "WARN: Failed to delete session: %v\n" , err )
2025-01-20 15:08:01 +00:00
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
2024-07-31 03:09:22 +00:00
return
}
2025-01-23 00:37:19 +00:00
http . SetCookie ( w , & http . Cookie {
Name : model . COOKIE_TOKEN ,
Expires : time . Now ( ) ,
Path : "/" ,
} )
2025-01-26 20:09:18 +00:00
err = logoutTemplate . Execute ( w , nil )
2025-01-23 00:37:19 +00:00
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to render logout page: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
}
} )
}
2025-01-26 23:41:35 +00:00
func requireAccount ( next http . Handler ) http . HandlerFunc {
2025-01-23 00:37:19 +00:00
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
if session . Account == nil {
2025-01-20 15:08:01 +00:00
// TODO: include context in redirect
http . Redirect ( w , r , "/admin/login" , http . StatusFound )
return
2024-08-02 21:48:26 +00:00
}
2025-01-23 00:37:19 +00:00
next . ServeHTTP ( w , r )
2024-07-31 03:09:22 +00:00
} )
2024-04-16 21:58:39 +00:00
}
2024-08-01 23:53:19 +00:00
func staticHandler ( ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
info , err := os . Stat ( filepath . Join ( "admin" , "static" , filepath . Clean ( r . URL . Path ) ) )
// does the file exist?
if err != nil {
if os . IsNotExist ( err ) {
http . NotFound ( w , r )
return
}
}
2024-04-16 21:58:39 +00:00
2024-08-01 23:53:19 +00:00
// is thjs a directory? (forbidden)
if info . IsDir ( ) {
http . NotFound ( w , r )
return
}
http . FileServer ( http . Dir ( filepath . Join ( "admin" , "static" ) ) ) . ServeHTTP ( w , r )
} )
2024-04-16 21:58:39 +00:00
}
2025-01-23 00:37:19 +00:00
func enforceSession ( app * model . AppState , next http . Handler ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2025-01-27 00:27:03 +00:00
session , err := controller . GetSessionFromRequest ( app . DB , r )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to retrieve session: %v\n" , err )
2025-01-26 23:41:35 +00:00
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
2025-01-23 00:37:19 +00:00
return
}
if session == nil {
// create a new session
session , err = controller . CreateSession ( app . DB , r . UserAgent ( ) )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to create session: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
http . SetCookie ( w , & http . Cookie {
Name : model . COOKIE_TOKEN ,
Value : session . Token ,
Expires : session . ExpiresAt ,
Secure : strings . HasPrefix ( app . Config . BaseUrl , "https" ) ,
HttpOnly : true ,
Path : "/" ,
} )
}
ctx := context . WithValue ( r . Context ( ) , "session" , session )
next . ServeHTTP ( w , r . WithContext ( ctx ) )
} )
}