diff --git a/README.md b/README.md index 8d62a73..2303093 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,21 @@ To run filething, run ```BASH -go generate +bun --cwd=./ui install +bun --bun --cwd=./ui run generate go build -tags netgo -ldflags=-s DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data ./filething ``` +Or if you want to run filething with SSR (you will need node on the target server), run + +```BASH +bun --cwd=./ui install +bun --bun --cwd=./ui run build +go build -tags netgo,ssr -ldflags=-s +DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data ./filething +``` + ### Contributing To run filething in dev mode with a hot reloading Ui server and auto rebuilding backend server, run diff --git a/go.mod b/go.mod index 9502d2c..41d7a39 100644 --- a/go.mod +++ b/go.mod @@ -21,10 +21,10 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/crypto v0.26.0 + golang.org/x/crypto v0.27.0 golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.24.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect golang.org/x/time v0.5.0 // indirect mellium.im/sasl v0.3.1 // indirect ) diff --git a/go.sum b/go.sum index ff881e2..ec8603e 100644 --- a/go.sum +++ b/go.sum @@ -39,16 +39,16 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 063f99b..147f2f1 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,3 @@ -//go:generate sh -c "NODE_ENV=production bun --cwd=./ui install" -//go:generate sh -c "NODE_ENV=production bun --bun --cwd=./ui run generate" package main import ( @@ -87,9 +85,12 @@ func main() { admin := api.Group("/admin") { admin.Use(middleware.AdminMiddleware()) - admin.GET("/system-status", routes.SystemStatus) - admin.GET("/get-users/:page", routes.GetUsers) - admin.GET("/get-total-users", routes.GetUsersCount) + admin.GET("/status", routes.SystemStatus) + admin.GET("/plans", routes.GetPlans) + admin.GET("/users", routes.GetUsers) + admin.GET("/users/:id", routes.GetUser) + admin.POST("/users/edit/:id", routes.EditUser) + admin.POST("/users/new", routes.CreateUser) } } diff --git a/middleware/auth.go b/middleware/auth.go index 84b7e09..7eacaad 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -26,10 +26,7 @@ func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc { return echo.NewHTTPError(http.StatusBadRequest, "Bad request") } - sessionToken := cookie.Value - - // Query the session and user data from PostgreSQL - sessionId, err := uuid.Parse(sessionToken) + sessionId, err := uuid.Parse(cookie.Value) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Bad request") } diff --git a/routes/admin.go b/routes/admin.go index 41606cb..fa8b0f2 100644 --- a/routes/admin.go +++ b/routes/admin.go @@ -5,51 +5,204 @@ import ( "filething/models" "fmt" "net/http" + "os" + "regexp" "runtime" "strconv" "time" "github.com/dustin/go-humanize" + "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/uptrace/bun" + "golang.org/x/crypto/bcrypt" ) func GetUsers(c echo.Context) error { db := c.Get("db").(*bun.DB) - pageStr := c.Param("page") + count, err := db.NewSelect().Model((*models.User)(nil)).Count(context.Background()) + + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Invalid page number"}) + } + + // this should be a query param not a URL param + pageStr := c.QueryParam("page") + if pageStr == "" { + pageStr = "0" + } + page, err := strconv.Atoi(pageStr) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid page number") + page = 0 } offset := page * 30 limit := 30 + if offset > count { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "Invalid page number"}) + } + var users []models.User err = db.NewSelect(). Model(&users). Limit(limit). Offset(offset). + Order("created_at ASC"). Scan(context.Background()) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve users") + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Failed to retrieve users"}) } - return c.JSON(http.StatusOK, users) + if users == nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "Invalid page number"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"users": users, "total_users": count}) } -func GetUsersCount(c echo.Context) error { +type UserEdit struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + PlanID int64 `json:"plan_id"` + Admin bool `json:"is_admin"` +} + +func EditUser(c echo.Context) error { db := c.Get("db").(*bun.DB) + id := c.Param("id") - count, err := db.NewSelect().Model(&models.User{}).Count(context.Background()) - - if err != nil { + var userEditData UserEdit + if err := c.Bind(&userEditData); err != nil { fmt.Println(err) - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve users") + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } - return c.JSON(http.StatusOK, map[string]int{"total_users": count}) + if !regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`).MatchString(userEditData.Email) { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "A valid email is required!"}) + } + + plan := models.Plan{ + ID: userEditData.PlanID, + } + planCount, err := db.NewSelect().Model(&plan).WherePK().Count(context.Background()) + if err != nil || planCount == 0 { + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Invalid plan id!"}) + } + + userId, err := uuid.Parse(id) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "An unknown error occoured!"}) + } + + var userData models.User + userData.ID = userId + + err = db.NewSelect().Model(&userData).WherePK().Relation("Plan").Scan(context.Background()) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + + if userEditData.Username != "" { + userData.Username = userEditData.Username + } + + if userEditData.Email != "" { + userData.Email = userEditData.Email + } + + if userEditData.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(userEditData.Password), 12) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + + userData.PasswordHash = string(hash) + } + + if userEditData.PlanID != 0 { + userData.PlanID = userEditData.PlanID + } + + userData.Admin = userEditData.Admin + + // update the user, but, if the password is empty, but dont use OmitZero because it will ignore is_admin if it's false + _, err = db.NewUpdate().Model(&userData).WherePK().Exec(context.Background()) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Successfully updated user"}) +} + +func GetPlans(c echo.Context) error { + db := c.Get("db").(*bun.DB) + + var plans []models.Plan + err := db.NewSelect().Model(&plans).Scan(context.Background()) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + + return c.JSON(http.StatusOK, plans) +} + +func CreateUser(c echo.Context) error { + var signupData models.SignupData + + if err := c.Bind(&signupData); err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + + if signupData.Username == "" || signupData.Password == "" || signupData.Email == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "A password, username and email are required!"}) + } + + // if email is not valid + if !regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`).MatchString(signupData.Email) { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "A valid email is required!"}) + } + + db := c.Get("db").(*bun.DB) + + hash, err := bcrypt.GenerateFromPassword([]byte(signupData.Password), 12) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + + user := &models.User{ + Username: signupData.Username, + Email: signupData.Email, + PasswordHash: string(hash), + PlanID: 1, // basic 10GB plan + } + _, err = db.NewInsert().Model(user).Exec(context.Background()) + + if err != nil { + return c.JSON(http.StatusConflict, map[string]string{"message": "A user with that email or username already exists!"}) + } + + err = db.NewSelect().Model(user).WherePK().Relation("Plan").Scan(context.Background()) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusNotFound, map[string]string{"message": "An unknown error occoured!"}) + } + + err = os.Mkdir(fmt.Sprintf("%s/%s", os.Getenv("STORAGE_PATH"), user.ID), os.ModePerm) + if err != nil { + fmt.Println(err) + return err + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Successfully created user"}) } // Stolen from Gitea https://github.com/go-gitea/gitea diff --git a/routes/auth.go b/routes/auth.go index e704f4b..31e17d9 100644 --- a/routes/auth.go +++ b/routes/auth.go @@ -70,6 +70,7 @@ func SignupHandler(c echo.Context) error { var signupData models.SignupData if err := c.Bind(&signupData); err != nil { + fmt.Println(err) return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } @@ -86,6 +87,7 @@ func SignupHandler(c echo.Context) error { hash, err := bcrypt.GenerateFromPassword([]byte(signupData.Password), 12) if err != nil { + fmt.Println(err) return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } @@ -118,6 +120,7 @@ func SignupHandler(c echo.Context) error { err = db.NewSelect().Model(user).WherePK().Relation("Plan").Scan(context.Background()) if err != nil { + fmt.Println(err) return c.JSON(http.StatusNotFound, map[string]string{"message": "An unknown error occoured!"}) } @@ -130,6 +133,7 @@ func SignupHandler(c echo.Context) error { session, err := GenerateSessionToken(db, user.ID) if err != nil { + fmt.Println(err) return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } @@ -157,20 +161,49 @@ func GenerateSessionToken(db *bun.DB, userId uuid.UUID) (*models.Session, error) } func GetUser(c echo.Context) error { - user := c.Get("user") - if user == nil { - return c.JSON(http.StatusNotFound, map[string]string{"message": "User not found"}) + if c.Param("id") == "" { + user := c.Get("user") + if user == nil { + return c.JSON(http.StatusNotFound, map[string]string{"message": "User not found"}) + } + + basePath := fmt.Sprintf("%s/%s/", os.Getenv("STORAGE_PATH"), user.(*models.User).ID) + storageUsage, err := calculateStorageUsage(basePath) + if err != nil { + return err + } + + user.(*models.User).Usage = storageUsage + + return c.JSON(http.StatusOK, user.(*models.User)) + } else { + // get a user from the db using the id parameter, this *should* only be used for admin since /api/admin/users/:id has + // a middleware that checks if the user is an admin, and it should be impossible to pass a param to this endpoint if it isnt that route + db := c.Get("db").(*bun.DB) + + user := new(models.User) + userId, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": "An unknown error occoured!"}) + } + + user.ID = userId + + err = db.NewSelect().Model(user).WherePK().Relation("Plan").Scan(context.Background()) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"message": "User not found"}) + } + + basePath := fmt.Sprintf("%s/%s/", os.Getenv("STORAGE_PATH"), user.ID) + storageUsage, err := calculateStorageUsage(basePath) + if err != nil { + return err + } + + user.Usage = storageUsage + + return c.JSON(http.StatusOK, user) } - - basePath := fmt.Sprintf("%s/%s/", os.Getenv("STORAGE_PATH"), user.(*models.User).ID) - storageUsage, err := calculateStorageUsage(basePath) - if err != nil { - return err - } - - user.(*models.User).Usage = storageUsage - - return c.JSON(http.StatusOK, user.(*models.User)) } func LogoutHandler(c echo.Context) error { diff --git a/server.go b/server.go index ba25e6c..8af080e 100644 --- a/server.go +++ b/server.go @@ -1,19 +1,37 @@ -//go:build !dev -// +build !dev +//go:build !dev && !ssr +// +build !dev,!ssr package main import ( "filething/ui" + "io/fs" "net/http" + "path/filepath" "strings" "github.com/labstack/echo/v4" ) +type embeddedFS struct { + baseFS fs.FS + prefix string +} + +func (fs *embeddedFS) Open(name string) (fs.File, error) { + // Prepend the prefix to the requested file name + publicPath := filepath.Join(fs.prefix, name) + return fs.baseFS.Open(publicPath) +} + +var publicFS = &embeddedFS{ + baseFS: ui.DistDirFS, + prefix: "public/", +} + func init() { initUi = func(e *echo.Echo) { - e.GET("/*", echo.StaticDirectoryHandler(ui.DistDirFS, false)) + // e.GET("/*", echo.StaticDirectoryHandler(publicFS, false)) e.HTTPErrorHandler = customHTTPErrorHandler } @@ -26,7 +44,7 @@ func customHTTPErrorHandler(err error, c echo.Context) { path := c.Request().URL.Path if !strings.HasPrefix(path, "/api") { - file, err := ui.DistDirFS.Open("404.html") + file, err := publicFS.Open("404.html") if err != nil { c.Logger().Error(err) } diff --git a/server_dev.go b/server_dev.go index 41d5118..5e9cdb0 100644 --- a/server_dev.go +++ b/server_dev.go @@ -4,13 +4,7 @@ package main import ( - "context" - "fmt" "net/url" - "os" - "os/exec" - "os/signal" - "syscall" "github.com/labstack/echo/v4" echoMiddleware "github.com/labstack/echo/v4/middleware" @@ -18,43 +12,7 @@ import ( func init() { initUi = func(e *echo.Echo) { - shutdown := make(chan os.Signal, 1) - signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) - go func() { - cmd := exec.Command("bun", "--cwd=ui", "run", "dev") - cmd.Stderr = os.Stderr - - // use a preocess group since otherwise the node processes spawned by bun wont die - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - err := cmd.Start() - if err != nil { - if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { - fmt.Println("Error sending SIGTERM to Nuxt dev server group:", err) - } - - fmt.Println("Error starting Nuxt dev server:", err) - return - } - - go func() { - <-shutdown - - if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { - fmt.Println("Error sending SIGTERM to Nuxt dev server group:", err) - } - }() - - if err := cmd.Wait(); err != nil { - fmt.Println("Error waiting for Nuxt dev server to exit:", err) - } - - fmt.Println("Nuxt dev server stopped") - - if err := e.Shutdown(context.Background()); err != nil { - fmt.Println("Error shutting down HTTP server:", err) - } - }() + spawnProcess("bun", []string{"--cwd=ui", "run", "dev"}, e) target := "localhost:3000" e.Group("/*").Use(echoMiddleware.ProxyWithConfig(echoMiddleware.ProxyConfig{ diff --git a/server_ssr.go b/server_ssr.go new file mode 100644 index 0000000..c36c458 --- /dev/null +++ b/server_ssr.go @@ -0,0 +1,81 @@ +//go:build ssr +// +build ssr + +package main + +import ( + "embed" + "filething/ui" + "io" + "net/url" + "os" + "path/filepath" + + "github.com/labstack/echo/v4" + echoMiddleware "github.com/labstack/echo/v4/middleware" +) + +func init() { + initUi = func(e *echo.Echo) { + tmpDir, err := os.MkdirTemp("", "filething-ssr") + if err != nil { + panic(err) + } + + err = copyEmbeddedFiles(ui.DistDir, ".output", tmpDir) + if err != nil { + panic(err) + } + + path := filepath.Join(tmpDir, "server/index.mjs") + spawnProcess("node", []string{path}, e) + + target := "localhost:3000" + e.Group("/*").Use(echoMiddleware.ProxyWithConfig(echoMiddleware.ProxyConfig{ + Balancer: echoMiddleware.NewRoundRobinBalancer([]*echoMiddleware.ProxyTarget{ + {URL: &url.URL{ + Scheme: "http", + Host: target, + }}, + }), + })) + } +} + +func copyEmbeddedFiles(fs embed.FS, sourcePath string, targetDir string) error { + entries, err := fs.ReadDir(sourcePath) + if err != nil { + return err + } + + for _, entry := range entries { + sourceFile := filepath.Join(sourcePath, entry.Name()) + destFile := filepath.Join(targetDir, entry.Name()) + + if entry.IsDir() { + os.Mkdir(destFile, 0755) + err := copyEmbeddedFiles(fs, sourceFile, destFile) + if err != nil { + return err + } + } else { + source, err := fs.Open(sourceFile) + if err != nil { + return err + } + defer source.Close() + + dest, err := os.Create(destFile) + if err != nil { + return err + } + defer dest.Close() + + _, err = io.Copy(dest, source) + if err != nil { + return err + } + } + } + return nil +} diff --git a/ui/components/Input.vue b/ui/components/Input.vue index b47c693..41494c0 100644 --- a/ui/components/Input.vue +++ b/ui/components/Input.vue @@ -12,6 +12,6 @@ function updateValue(value) { \ No newline at end of file diff --git a/ui/components/Nav/index.vue b/ui/components/Nav/index.vue index cb32b5b..a005326 100644 --- a/ui/components/Nav/index.vue +++ b/ui/components/Nav/index.vue @@ -87,7 +87,7 @@ const changeTheme = () => {