diff --git a/README.md b/README.md index c19826f..8d62a73 100644 --- a/README.md +++ b/README.md @@ -20,5 +20,5 @@ DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data ./fi To run filething in dev mode with a hot reloading Ui server and auto rebuilding backend server, run ```BASH -DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data CompileDaemon --build="go build -tags netgo,dev -ldflags=-s" --command=./filething --exclude-dir=data/ --exclude-dir=ui. --graceful-kill +DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data CompileDaemon --build="go build -tags netgo,dev -ldflags=-s" --command=./filething --exclude-dir=data/ --exclude-dir=ui/ --graceful-kill ``` diff --git a/go.mod b/go.mod index 0865568..9502d2c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require github.com/google/uuid v1.6.0 require ( + github.com/dustin/go-humanize v1.0.1 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/labstack/echo/v4 v4.12.0 diff --git a/go.sum b/go.sum index 85cbdf6..ff881e2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/main.go b/main.go index 1c6b7d7..063f99b 100644 --- a/main.go +++ b/main.go @@ -8,11 +8,10 @@ import ( "filething/middleware" "filething/models" "filething/routes" - "filething/ui" "fmt" "net/http" "os" - "strings" + "time" "github.com/labstack/echo/v4" echoMiddleware "github.com/labstack/echo/v4/middleware" @@ -84,61 +83,32 @@ func main() { api.GET("/files/get/*", routes.GetFiles) api.GET("/files/download*", routes.GetFile) api.POST("/files/delete*", routes.DeleteFiles) + + 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) + } } // redirects to the proper pages if you are trying to access one that expects you have/dont have an api key // this isnt explicitly required, but it provides a better experience than doing this same thing clientside e.Use(middleware.AuthCheckMiddleware) - // calls out to a function set by either server.go server_dev.go based on the value of the dev tag, and hosts + // calls out to a function set by either server.go server_dev.go based on the presence of the dev tag, and hosts // either the static files that get embedded into the binary in ui/embed.go or proxies the dev server that gets // run in the provided function initUi(e) - e.HTTPErrorHandler = customHTTPErrorHandler + routes.AppStartTime = time.Now().UTC() if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed { fmt.Println("Error starting HTTP server:", err) } } -// Custom Error handling since Nuxt relies on the 404 page for dynamic pages we still want api routes to use the default -// error handling built into echo -func customHTTPErrorHandler(err error, c echo.Context) { - if he, ok := err.(*echo.HTTPError); ok && he.Code == http.StatusNotFound { - path := c.Request().URL.Path - - if !strings.HasPrefix(path, "/api") { - file, err := ui.DistDirFS.Open("404.html") - if err != nil { - c.Logger().Error(err) - } - - fileInfo, err := file.Stat() - if err != nil { - c.Logger().Error(err) - } - - fileBuf := make([]byte, fileInfo.Size()) - _, err = file.Read(fileBuf) - defer func() { - if err := file.Close(); err != nil { - panic(err) - } - }() - if err != nil { - c.Logger().Error(err) - panic(err) - } - - c.HTML(http.StatusNotFound, string(fileBuf)) - return - } - } - - c.Echo().DefaultHTTPErrorHandler(err, c) -} - // creates tables in the db if they dont already exist func createSchema(db *bun.DB) error { models := []interface{}{ diff --git a/middleware/admin.go b/middleware/admin.go new file mode 100644 index 0000000..440c16d --- /dev/null +++ b/middleware/admin.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "filething/models" + "net/http" + + "github.com/labstack/echo/v4" +) + +func AdminMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + user := c.Get("user").(*models.User) + + if !user.Admin { + return echo.NewHTTPError(http.StatusForbidden, "You are not an administrator") + } + + return next(c) + } + } +} diff --git a/middleware/route.go b/middleware/route.go index ec1483c..48b7553 100644 --- a/middleware/route.go +++ b/middleware/route.go @@ -41,6 +41,10 @@ func AuthCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return c.Redirect(http.StatusFound, "/login") } + if strings.Contains(path, "/admin") && !authenticated { + return c.Redirect(http.StatusFound, "/login") + } + return next(c) } } diff --git a/models/user.go b/models/user.go index cd4dd10..5baa656 100644 --- a/models/user.go +++ b/models/user.go @@ -1,6 +1,8 @@ package models import ( + "time" + "github.com/google/uuid" "github.com/uptrace/bun" ) @@ -18,13 +20,15 @@ type SignupData struct { type User struct { bun.BaseModel `bun:"table:users"` - ID uuid.UUID `bun:"id,pk,type:uuid,default:uuid_generate_v4()" json:"id"` - Username string `bun:"username,notnull,unique" json:"username"` - Email string `bun:"email,notnull,unique" json:"email"` - PasswordHash string `bun:"passwordHash,notnull" json:"-"` + ID uuid.UUID `bun:",pk,type:uuid,default:uuid_generate_v4()" json:"id"` + Username string `bun:",notnull,unique" json:"username"` + Email string `bun:",notnull,unique" json:"email"` + PasswordHash string `bun:",notnull" json:"-"` PlanID int64 `bun:"plan_id,notnull" json:"-"` Plan Plan `bun:"rel:belongs-to,join:plan_id=id" json:"plan"` Usage int64 `bun:"-" json:"usage"` + CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at"` + Admin bool `bin:"admin,type:bool" json:"is_admin"` } type Session struct { diff --git a/routes/admin.go b/routes/admin.go new file mode 100644 index 0000000..41606cb --- /dev/null +++ b/routes/admin.go @@ -0,0 +1,172 @@ +package routes + +import ( + "context" + "filething/models" + "fmt" + "net/http" + "runtime" + "strconv" + "time" + + "github.com/dustin/go-humanize" + "github.com/labstack/echo/v4" + "github.com/uptrace/bun" +) + +func GetUsers(c echo.Context) error { + db := c.Get("db").(*bun.DB) + + pageStr := c.Param("page") + page, err := strconv.Atoi(pageStr) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid page number") + } + + offset := page * 30 + limit := 30 + + var users []models.User + err = db.NewSelect(). + Model(&users). + Limit(limit). + Offset(offset). + Scan(context.Background()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve users") + } + + return c.JSON(http.StatusOK, users) +} + +func GetUsersCount(c echo.Context) error { + db := c.Get("db").(*bun.DB) + + count, err := db.NewSelect().Model(&models.User{}).Count(context.Background()) + + if err != nil { + fmt.Println(err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve users") + } + + return c.JSON(http.StatusOK, map[string]int{"total_users": count}) +} + +// Stolen from Gitea https://github.com/go-gitea/gitea +func SystemStatus(c echo.Context) error { + updateSystemStatus() + return c.JSON(http.StatusOK, map[string]interface{}{ + "uptime": sysStatus.StartTime, + "num_goroutine": sysStatus.NumGoroutine, + "cur_mem_usage": sysStatus.MemAllocated, + "total_mem_usage": sysStatus.MemTotal, + "mem_obtained": sysStatus.MemSys, + "ptr_lookup_times": sysStatus.Lookups, + "mem_allocations": sysStatus.MemMallocs, + "mem_frees": sysStatus.MemFrees, + "cur_heap_usage": sysStatus.HeapAlloc, + "heap_mem_obtained": sysStatus.HeapSys, + "heap_mem_idle": sysStatus.HeapIdle, + "heap_mem_inuse": sysStatus.HeapInuse, + "heap_mem_release": sysStatus.HeapReleased, + "heap_objects": sysStatus.HeapObjects, + "bootstrap_stack_usage": sysStatus.StackInuse, + "stack_mem_obtained": sysStatus.StackSys, + "mspan_structures_usage": sysStatus.MSpanInuse, + "mspan_structures_obtained": sysStatus.MSpanSys, + "mcache_structures_usage": sysStatus.MSpanInuse, + "mcache_structures_obtained": sysStatus.MCacheSys, + "buck_hash_sys": sysStatus.BuckHashSys, + "gc_sys": sysStatus.GCSys, + "other_sys": sysStatus.OtherSys, + "next_gc": sysStatus.NextGC, + "last_gc_time": sysStatus.LastGCTime, + "pause_total_ns": sysStatus.PauseTotalNs, + "pause_ns": sysStatus.PauseNs, + "num_gc": sysStatus.NumGC, + }) +} + +var AppStartTime time.Time +var sysStatus struct { + StartTime string + NumGoroutine int + + // General statistics. + MemAllocated string // bytes allocated and still in use + MemTotal string // bytes allocated (even if freed) + MemSys string // bytes obtained from system (sum of XxxSys below) + Lookups uint64 // number of pointer lookups + MemMallocs uint64 // number of mallocs + MemFrees uint64 // number of frees + + // Main allocation heap statistics. + HeapAlloc string // bytes allocated and still in use + HeapSys string // bytes obtained from system + HeapIdle string // bytes in idle spans + HeapInuse string // bytes in non-idle span + HeapReleased string // bytes released to the OS + HeapObjects uint64 // total number of allocated objects + + // Low-level fixed-size structure allocator statistics. + // Inuse is bytes used now. + // Sys is bytes obtained from system. + StackInuse string // bootstrap stacks + StackSys string + MSpanInuse string // mspan structures + MSpanSys string + MCacheInuse string // mcache structures + MCacheSys string + BuckHashSys string // profiling bucket hash table + GCSys string // GC metadata + OtherSys string // other system allocations + + // Garbage collector statistics. + NextGC string // next run in HeapAlloc time (bytes) + LastGCTime string // last run time + PauseTotalNs string + PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] + NumGC uint32 +} + +func updateSystemStatus() { + sysStatus.StartTime = AppStartTime.Format(time.RFC3339) + + m := new(runtime.MemStats) + runtime.ReadMemStats(m) + sysStatus.NumGoroutine = runtime.NumGoroutine() + + sysStatus.MemAllocated = FileSize(int64(m.Alloc)) + sysStatus.MemTotal = FileSize(int64(m.TotalAlloc)) + sysStatus.MemSys = FileSize(int64(m.Sys)) + sysStatus.Lookups = m.Lookups + sysStatus.MemMallocs = m.Mallocs + sysStatus.MemFrees = m.Frees + + sysStatus.HeapAlloc = FileSize(int64(m.HeapAlloc)) + sysStatus.HeapSys = FileSize(int64(m.HeapSys)) + sysStatus.HeapIdle = FileSize(int64(m.HeapIdle)) + sysStatus.HeapInuse = FileSize(int64(m.HeapInuse)) + sysStatus.HeapReleased = FileSize(int64(m.HeapReleased)) + sysStatus.HeapObjects = m.HeapObjects + + sysStatus.StackInuse = FileSize(int64(m.StackInuse)) + sysStatus.StackSys = FileSize(int64(m.StackSys)) + sysStatus.MSpanInuse = FileSize(int64(m.MSpanInuse)) + sysStatus.MSpanSys = FileSize(int64(m.MSpanSys)) + sysStatus.MCacheInuse = FileSize(int64(m.MCacheInuse)) + sysStatus.MCacheSys = FileSize(int64(m.MCacheSys)) + sysStatus.BuckHashSys = FileSize(int64(m.BuckHashSys)) + sysStatus.GCSys = FileSize(int64(m.GCSys)) + sysStatus.OtherSys = FileSize(int64(m.OtherSys)) + + sysStatus.NextGC = FileSize(int64(m.NextGC)) + sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339) + sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) + sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) + sysStatus.NumGC = m.NumGC +} + +func FileSize(s int64) string { + return humanize.IBytes(uint64(s)) +} diff --git a/routes/auth.go b/routes/auth.go index cda6654..e704f4b 100644 --- a/routes/auth.go +++ b/routes/auth.go @@ -64,6 +64,8 @@ func LoginHandler(c echo.Context) error { return c.JSON(http.StatusOK, user) } +var firstUserCreated *bool + func SignupHandler(c echo.Context) error { var signupData models.SignupData @@ -87,11 +89,22 @@ func SignupHandler(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } + if firstUserCreated == nil { + count, err := db.NewSelect().Model((*models.User)(nil)).Count(context.Background()) + if err != nil { + return fmt.Errorf("failed to count plans: %w", err) + } + + firstUserCreated = new(bool) + *firstUserCreated = count != 0 + } + user := &models.User{ Username: signupData.Username, Email: signupData.Email, PasswordHash: string(hash), PlanID: 1, // basic 10GB plan + Admin: !*firstUserCreated, } _, err = db.NewInsert().Model(user).Exec(context.Background()) @@ -99,6 +112,10 @@ func SignupHandler(c echo.Context) error { return c.JSON(http.StatusConflict, map[string]string{"message": "A user with that email or username already exists!"}) } + if !*firstUserCreated { + *firstUserCreated = true + } + err = db.NewSelect().Model(user).WherePK().Relation("Plan").Scan(context.Background()) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"message": "An unknown error occoured!"}) diff --git a/server.go b/server.go index 37c3bde..ba25e6c 100644 --- a/server.go +++ b/server.go @@ -5,6 +5,8 @@ package main import ( "filething/ui" + "net/http" + "strings" "github.com/labstack/echo/v4" ) @@ -12,5 +14,44 @@ import ( func init() { initUi = func(e *echo.Echo) { e.GET("/*", echo.StaticDirectoryHandler(ui.DistDirFS, false)) + + e.HTTPErrorHandler = customHTTPErrorHandler } } + +// Custom Error handling since Nuxt relies on the 404 page for dynamic pages we still want api routes to use the default +// error handling built into echo +func customHTTPErrorHandler(err error, c echo.Context) { + if he, ok := err.(*echo.HTTPError); ok && he.Code == http.StatusNotFound { + path := c.Request().URL.Path + + if !strings.HasPrefix(path, "/api") { + file, err := ui.DistDirFS.Open("404.html") + if err != nil { + c.Logger().Error(err) + } + + fileInfo, err := file.Stat() + if err != nil { + c.Logger().Error(err) + } + + fileBuf := make([]byte, fileInfo.Size()) + _, err = file.Read(fileBuf) + defer func() { + if err := file.Close(); err != nil { + panic(err) + } + }() + if err != nil { + c.Logger().Error(err) + panic(err) + } + + c.HTML(http.StatusNotFound, string(fileBuf)) + return + } + } + + c.Echo().DefaultHTTPErrorHandler(err, c) +} diff --git a/ui/app.vue b/ui/app.vue index cc25a49..548ef22 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -1,5 +1,7 @@ diff --git a/ui/assets/css/main.css b/ui/assets/css/main.css index 6a997b6..63b63d9 100644 --- a/ui/assets/css/main.css +++ b/ui/assets/css/main.css @@ -48,8 +48,10 @@ } html, body { - overflow: hidden !important; + min-height: 100vh; + overflow-x: hidden !important; background-color: rgb(var(--color-base)); + overflow-wrap: break-word; color: rgb(var(--color-text)); } diff --git a/ui/bun.lockb b/ui/bun.lockb index c2c912f..5a7c897 100755 Binary files a/ui/bun.lockb and b/ui/bun.lockb differ diff --git a/ui/components/Breadcrumbs.vue b/ui/components/Breadcrumbs.vue index 340cfc6..439449f 100644 --- a/ui/components/Breadcrumbs.vue +++ b/ui/components/Breadcrumbs.vue @@ -25,7 +25,7 @@ const crumbs = computed(() => { - {{ crumb.name }} diff --git a/ui/components/Checkbox.vue b/ui/components/Checkbox.vue index 9d6d918..311535d 100644 --- a/ui/components/Checkbox.vue +++ b/ui/components/Checkbox.vue @@ -1,6 +1,6 @@