package main import ( "errors" "fmt" "log" "net/http" "os" "path/filepath" "strings" "time" "arimelody-web/admin" "arimelody-web/api" "arimelody-web/controller" "arimelody-web/global" "arimelody-web/model" "arimelody-web/templates" "arimelody-web/view" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) // used for database migrations const DB_VERSION = 1 const DEFAULT_PORT int64 = 8080 func main() { fmt.Printf("made with <3 by ari melody\n\n") // TODO: refactor `global` to `AppState` // this should contain `Config` and `DB`, and be passed through to all // handlers that need it. it's better than weird static globals everywhere! // initialise database connection if global.Config.DB.Host == "" { fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") os.Exit(1) } if global.Config.DB.Name == "" { fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") os.Exit(1) } if global.Config.DB.User == "" { fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") os.Exit(1) } if global.Config.DB.Pass == "" { fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") os.Exit(1) } var err error global.DB, err = sqlx.Connect( "postgres", fmt.Sprintf( "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable", global.Config.DB.Host, global.Config.DB.Port, global.Config.DB.User, global.Config.DB.Name, global.Config.DB.Pass, ), ) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) os.Exit(1) } global.DB.SetConnMaxLifetime(time.Minute * 3) global.DB.SetMaxOpenConns(10) global.DB.SetMaxIdleConns(10) defer global.DB.Close() // handle command arguments if len(os.Args) > 1 { arg := os.Args[1] switch arg { case "createTOTP": if len(os.Args) < 4 { fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n") os.Exit(1) } username := os.Args[2] totpName := os.Args[3] secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) account, err := controller.GetAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) os.Exit(1) } if account == nil { fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) os.Exit(1) } totp := model.TOTP { AccountID: account.ID, Name: totpName, Secret: string(secret), } err = controller.CreateTOTP(global.DB, &totp) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) os.Exit(1) } url := controller.GenerateTOTPURI(account.Username, totp.Secret) fmt.Printf("%s\n", url) return case "deleteTOTP": if len(os.Args) < 4 { fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n") os.Exit(1) } username := os.Args[2] totpName := os.Args[3] account, err := controller.GetAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) os.Exit(1) } if account == nil { fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) os.Exit(1) } err = controller.DeleteTOTP(global.DB, account.ID, totpName) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) os.Exit(1) } fmt.Printf("TOTP method \"%s\" deleted.\n", totpName) return case "listTOTP": if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n") os.Exit(1) } username := os.Args[2] account, err := controller.GetAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) os.Exit(1) } if account == nil { fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) os.Exit(1) } totps, err := controller.GetTOTPsForAccount(global.DB, account.ID) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err) os.Exit(1) } for i, totp := range totps { fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) } if len(totps) == 0 { fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username) } return case "testTOTP": if len(os.Args) < 4 { fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n") os.Exit(1) } username := os.Args[2] totpName := os.Args[3] account, err := controller.GetAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) os.Exit(1) } if account == nil { fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) os.Exit(1) } totp, err := controller.GetTOTP(global.DB, account.ID, totpName) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err) os.Exit(1) } if totp == nil { fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) os.Exit(1) } code := controller.GenerateTOTP(totp.Secret, 0) fmt.Printf("%s\n", code) return case "createInvite": fmt.Printf("Creating invite...\n") invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create invite code: %v\n", err) os.Exit(1) } fmt.Printf("Here you go! This code expires in 24 hours: %s\n", invite.Code) return case "purgeInvites": fmt.Printf("Deleting all invites...\n") err := controller.DeleteAllInvites(global.DB) if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete invites: %v\n", err) os.Exit(1) } fmt.Printf("Invites deleted successfully.\n") return case "listAccounts": accounts, err := controller.GetAllAccounts(global.DB) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch accounts: %v\n", err) os.Exit(1) } for _, account := range accounts { fmt.Printf( "User: %s\n" + "\tID: %s\n" + "\tEmail: %s\n" + "\tCreated: %s\n", account.Username, account.ID, account.Email, account.CreatedAt, ) } return case "deleteAccount": if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") os.Exit(1) } username := os.Args[2] fmt.Printf("Deleting account \"%s\"...\n", username) account, err := controller.GetAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) os.Exit(1) } if account == nil { fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) os.Exit(1) } fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) res := "" fmt.Scanln(&res) if !strings.HasPrefix(res, "y") { return } err = controller.DeleteAccount(global.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) os.Exit(1) } fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) return } // command help fmt.Print( "Available commands:\n\n" + "createTOTP :\n\tCreates a timed one-time passcode method.\n" + "listTOTP :\n\tLists an account's TOTP methods.\n" + "deleteTOTP :\n\tDeletes an account's TOTP method.\n" + "testTOTP :\n\tGenerates the code for an account's TOTP method.\n" + "\n" + "createInvite:\n\tCreates an invite code to register new accounts.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" + "listAccounts:\n\tLists all active accounts.\n", "deleteAccount :\n\tDeletes an account with a given `username`.\n", ) return } // handle DB migrations controller.CheckDBVersionAndMigrate(global.DB) // initial invite code accountsCount := 0 err = global.DB.Get(&accountsCount, "SELECT count(*) FROM account") if err != nil { panic(err) } if accountsCount == 0 { _, err := global.DB.Exec("DELETE FROM invite") if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) os.Exit(1) } invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) os.Exit(1) } fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) } // delete expired invites err = controller.DeleteExpiredInvites(global.DB) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) os.Exit(1) } // start the web server! mux := createServeMux() fmt.Printf("Now serving at %s:%d\n", global.Config.BaseUrl, global.Config.Port) log.Fatal( http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), global.HTTPLog(global.DefaultHeaders(mux)), )) } func createServeMux() *http.ServeMux { mux := http.NewServeMux() mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(global.DB))) mux.Handle("/api/", http.StripPrefix("/api", api.Handler(global.DB))) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(global.DB))) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(global.Config.DataDirectory, "uploads")))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } if r.URL.Path == "/" || r.URL.Path == "/index.html" { err := templates.Pages["index"].Execute(w, nil) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } staticHandler("public").ServeHTTP(w, r) })) return mux } func staticHandler(directory string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) // does the file exist? if err != nil { if errors.Is(err, os.ErrNotExist) { http.NotFound(w, r) return } } // is thjs a directory? (forbidden) if info.IsDir() { http.NotFound(w, r) return } http.FileServer(http.Dir(directory)).ServeHTTP(w, r) }) }