arimelody.me/main.go

393 lines
13 KiB
Go

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 <username> <name>:\n\tCreates a timed one-time passcode method.\n" +
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
"testTOTP <username> <name>:\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 <username>:\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)
})
}