diff --git a/.gitignore b/.gitignore index fb18888..9e01575 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ filething # nuxt buildCache is bugged and weird on my setup -node_modules \ No newline at end of file +node_modules + +data/ \ No newline at end of file diff --git a/main.go b/main.go index 53537b8..8340b10 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ //go:generate bun --cwd=./ui install -//go:generate bun --cwd=./ui run generate +//go:generate bun --bun --cwd=./ui run generate package main import ( @@ -10,6 +10,7 @@ import ( "filething/routes" "filething/ui" "fmt" + "log" "net/http" "os" "strings" @@ -27,7 +28,7 @@ func main() { dbUser := os.Getenv("DB_USER") dbPasswd := os.Getenv("DB_PASSWD") - if dbHost == "" || dbName == "" || dbUser == "" { + if dbHost == "" || dbName == "" || dbUser == "" || os.Getenv("STORAGE_PATH") == "" { panic("Missing database environment variabled!") } @@ -41,6 +42,11 @@ func main() { panic(err) } + err = seedPlans(db) + if err != nil { + panic(err) + } + e := echo.New() e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { @@ -64,17 +70,20 @@ func main() { { api.POST("/login", routes.LoginHandler) api.POST("/signup", routes.SignupHandler) + + // everything past this needs auth api.Use(middleware.SessionMiddleware(db)) - api.GET("/user", func(c echo.Context) error { - user := c.Get("user").(*models.User) - message := fmt.Sprintf("You are %s", user.ID) - return c.JSON(http.StatusOK, map[string]string{"message": message}) - }) - api.GET("/hello", func(c echo.Context) error { - return c.JSON(http.StatusOK, map[string]string{"message": "Hello, World!!!"}) - }) + api.GET("/user", routes.GetUser) + api.GET("/user/usage", routes.GetUsage) + + api.POST("/upload*", routes.UploadFile) + api.GET("/files*", routes.GetFiles) } + // 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) + e.GET("/*", echo.StaticDirectoryHandler(ui.DistDirFS, false)) e.HTTPErrorHandler = customHTTPErrorHandler @@ -83,8 +92,6 @@ func main() { } func customHTTPErrorHandler(err error, c echo.Context) { - c.Logger().Error(err) - if he, ok := err.(*echo.HTTPError); ok && he.Code == http.StatusNotFound { path := c.Request().URL.Path @@ -123,6 +130,7 @@ func createSchema(db *bun.DB) error { models := []interface{}{ (*models.User)(nil), (*models.Session)(nil), + (*models.Plan)(nil), } ctx := context.Background() @@ -134,3 +142,30 @@ func createSchema(db *bun.DB) error { } return nil } + +func seedPlans(db *bun.DB) error { + ctx := context.Background() + count, err := db.NewSelect().Model((*models.Plan)(nil)).Count(ctx) + if err != nil { + return fmt.Errorf("failed to count plans: %w", err) + } + + // If the table is not empty, no need to seed + if count > 0 { + return nil + } + + plans := []models.Plan{ + {MaxStorage: 10 * 1024 * 1024 * 1024}, // 10GB + {MaxStorage: 50 * 1024 * 1024 * 1024}, // 50GB + {MaxStorage: 100 * 1024 * 1024 * 1024}, // 100GB + } + + _, err = db.NewInsert().Model(&plans).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to seed plans: %w", err) + } + + log.Println("Successfully seeded the plans table") + return nil +} diff --git a/middleware/auth.go b/middleware/auth.go index c18e342..84b7e09 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -12,15 +12,6 @@ import ( "github.com/uptrace/bun" ) -// import ( -// "database/sql" -// "net/http" - -// "github.com/go-pg/pg/v10" - -// "github.com/labstack/echo/v4" -// ) - const UserContextKey = "user" func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc { @@ -46,7 +37,7 @@ func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc { session := &models.Session{ ID: sessionId, } - err = db.NewSelect().Model(session).Relation("User").WherePK().Scan(context.Background()) + err = db.NewSelect().Model(session).WherePK().Scan(context.Background()) if err != nil { fmt.Println(err) @@ -56,7 +47,18 @@ func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc { return echo.NewHTTPError(http.StatusInternalServerError, "Database error") } - user := &session.User + user := &models.User{ + ID: session.UserID, + } + err = db.NewSelect().Model(user).Relation("Plan").WherePK().Scan(context.Background()) + + if err != nil { + if err == sql.ErrNoRows { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session token") + } + fmt.Println(err) + return echo.NewHTTPError(http.StatusInternalServerError, "Database error") + } // Store the user in the context c.Set(UserContextKey, user) diff --git a/middleware/route.go b/middleware/route.go new file mode 100644 index 0000000..ffcfe6b --- /dev/null +++ b/middleware/route.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +var unauthenticatedPages = []string{ + "/login", + "/signup", + "/", +} + +var authenticatedPages = []string{ + "/home", +} + +func AuthCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + path := c.Request().URL.Path + _, cookieErr := c.Cookie("sessionToken") + authenticated := cookieErr == nil + + if Contains(unauthenticatedPages, path) && authenticated { + return c.Redirect(http.StatusFound, "/home") + } + + if Contains(authenticatedPages, path) && !authenticated { + return c.Redirect(http.StatusFound, "/login") + } + + if strings.Contains(path, "/home") && !authenticated { + return c.Redirect(http.StatusFound, "/login") + } + + return next(c) + } +} + +func Contains(s []string, element string) bool { + for _, v := range s { + if v == element { + return true + } + } + return false +} diff --git a/models/user.go b/models/user.go index 58d5b2f..a6e04a5 100644 --- a/models/user.go +++ b/models/user.go @@ -17,16 +17,24 @@ type SignupData struct { } type User struct { - bun.BaseModel `bun:"table:users,alias:u"` - ID uuid.UUID `bun:",pk,type:uuid,default:uuid_generate_v4()"` - Username string `bun:"username,notnull,unique"` - Email string `bun:"email,notnull,unique"` - PasswordHash string `bun:"passwordHash,notnull"` + 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:"-"` + PlanID int64 `bun:"plan_id,notnull" json:"-"` + Plan Plan `bun:"rel:belongs-to,join:plan_id=id" json:"plan"` } type Session struct { - bun.BaseModel `bun:"table:sessions,alias:u"` - ID uuid.UUID `bun:",pk,type:uuid,default:uuid_generate_v4()"` + bun.BaseModel `bun:"table:sessions"` + ID uuid.UUID `bun:"id,pk,type:uuid,default:uuid_generate_v4()"` UserID uuid.UUID `bun:"user_id,notnull,type:uuid"` User User `bun:"rel:belongs-to,join:user_id=id"` } + +type Plan struct { + bun.BaseModel `bun:"table:plans"` + ID int64 `bun:"id,pk,autoincrement" json:"id"` + MaxStorage int64 `bun:"max_storage,notnull" json:"max_storage"` +} diff --git a/routes/auth.go b/routes/auth.go index e3c7d56..ed76e98 100644 --- a/routes/auth.go +++ b/routes/auth.go @@ -3,8 +3,11 @@ package routes import ( "context" "filething/models" + "fmt" "net/http" + "os" "regexp" + "time" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -40,33 +43,17 @@ func LoginHandler(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } + expiration := time.Now().Add(time.Hour * 24 * 365 * 100) + c.SetCookie(&http.Cookie{ Name: "sessionToken", Value: session.ID.String(), SameSite: http.SameSiteStrictMode, + Expires: expiration, Path: "/", }) return c.JSON(http.StatusOK, map[string]string{"message": "Login successful!"}) - - // sessionID := uuid.New().String() - // session := &models.Session{ID: sessionID, UserID: user.ID, ExpiresAt: time.Now().Add(time.Hour * 24)} - - // key := "session:" + session.ID - // err = client.HSet(ctx, key, session).Err() - - // if err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"message": "An unknown error occoured!"}) - // return - // } - - // http.SetCookie(c.Writer, &http.Cookie{ - // Name: "sessionToken", - // Value: sessionID, - // Path: "/", - // }) - - // c.JSON(http.StatusOK, gin.H{"message": "Login successful"}) } func SignupHandler(c echo.Context) error { @@ -96,6 +83,7 @@ func SignupHandler(c echo.Context) error { Username: signupData.Username, Email: signupData.Email, PasswordHash: string(hash), + PlanID: 1, // basic 10GB plan } _, err = db.NewInsert().Model(user).Exec(context.Background()) @@ -103,28 +91,33 @@ func SignupHandler(c echo.Context) error { return c.JSON(http.StatusConflict, map[string]string{"message": "A user with that email or username already exists!"}) } + err = os.Mkdir(fmt.Sprintf("%s/%s", os.Getenv("STORAGE_PATH"), user.ID), os.ModePerm) + if err != nil { + fmt.Println(err) + return err + } + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) + } + session, err := GenerateSessionToken(db, user.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) } + expiration := time.Now().Add(time.Hour * 24 * 365 * 100) + c.SetCookie(&http.Cookie{ Name: "sessionToken", Value: session.ID.String(), SameSite: http.SameSiteStrictMode, + Expires: expiration, Path: "/", }) return c.JSON(http.StatusOK, map[string]string{"message": "Signup successful!"}) - - // http.SetCookie(c.Writer, &http.Cookie{ - // Name: "sessionToken", - // Value: sessionID, - // Path: "/", - // }) - - // c.JSON(http.StatusOK, gin.H{"message": "Signup successful"}) } func GenerateSessionToken(db *bun.DB, userId uuid.UUID) (*models.Session, error) { @@ -136,3 +129,12 @@ func GenerateSessionToken(db *bun.DB, userId uuid.UUID) (*models.Session, error) return session, err } + +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"}) + } + + return c.JSON(http.StatusOK, user.(*models.User)) +} diff --git a/routes/files.go b/routes/files.go new file mode 100644 index 0000000..8c1272f --- /dev/null +++ b/routes/files.go @@ -0,0 +1,194 @@ +package routes + +import ( + "filething/models" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" +) + +type UploadResponse struct { + Usage int64 `json:"usage"` + File File `json:"file"` +} + +func UploadFile(c echo.Context) error { + user := c.Get("user").(*models.User) + + fullPath := strings.Trim(c.Param("*"), "/") + basePath := fmt.Sprintf("%s/%s/%s/", os.Getenv("STORAGE_PATH"), user.ID, fullPath) + + currentUsage, err := calculateStorageUsage(basePath) + if err != nil { + fmt.Println(err) + return err + } + + reader, err := c.Request().MultipartReader() + if err != nil { + fmt.Println(err) + return err + } + + part, err := reader.NextPart() + if err != nil { + fmt.Println(err) + return err + } + + filepath := filepath.Join(basePath, part.FileName()) + + if _, err = os.Stat(filepath); err == nil { + return c.JSON(http.StatusConflict, map[string]string{"message": "File with that name already exists"}) + } + + dst, err := os.Create(filepath) + if err != nil { + fmt.Println(err) + return err + } + defer dst.Close() + + // Read the file manually because otherwise we are limited by the arbitrarily small size of /tmp + buffer := make([]byte, 4096) + totalSize := int64(0) + + for { + n, readErr := part.Read(buffer) + + if readErr != nil && readErr == io.ErrUnexpectedEOF { + dst.Close() + os.Remove(filepath) + return c.JSON(http.StatusRequestTimeout, map[string]string{"message": "Upload canceled"}) + } + + if readErr != nil && readErr != io.EOF { + fmt.Println(err) + return readErr + } + + totalSize += int64(n) + + if currentUsage+totalSize > user.Plan.MaxStorage { + dst.Close() + os.Remove(filepath) + return c.JSON(http.StatusInsufficientStorage, map[string]string{"message": "Insufficient storage space"}) + } + + if _, err := dst.Write(buffer[:n]); err != nil { + fmt.Println(err) + return err + } + + if n == 0 || readErr == io.EOF { + entry, err := os.Stat(filepath) + + if err != nil { + fmt.Println(err) + return err + } + + uploadFile := &UploadResponse{ + Usage: currentUsage + totalSize, + File: File{ + Name: entry.Name(), + IsDir: entry.IsDir(), + Size: entry.Size(), + LastModified: entry.ModTime().Format("1/2/2006"), + }, + } + + return c.JSON(http.StatusOK, uploadFile) + } + } +} + +func calculateStorageUsage(basePath string) (int64, error) { + var totalSize int64 + + // Read the directory + entries, err := os.ReadDir(basePath) + if err != nil { + return 0, err + } + + // Iterate over directory entries + for _, entry := range entries { + if entry.IsDir() { + // Recursively calculate size of directories + dirPath := filepath.Join(basePath, entry.Name()) + dirSize, err := calculateStorageUsage(dirPath) + if err != nil { + return 0, err + } + totalSize += dirSize + } else { + // Calculate size of file + _ = filepath.Join(basePath, entry.Name()) + fileInfo, err := entry.Info() + if err != nil { + return 0, err + } + totalSize += fileInfo.Size() + } + } + + return totalSize, nil +} + +type File struct { + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + LastModified string `json:"last_modified"` +} + +func GetFiles(c echo.Context) error { + user := c.Get("user").(*models.User) + + fullPath := strings.Trim(c.Param("*"), "/") + basePath := fmt.Sprintf("%s/%s/%s/", os.Getenv("STORAGE_PATH"), user.ID, fullPath) + + f, err := os.Open(basePath) + if err != nil { + fmt.Println(err) + return err + } + + files, err := f.Readdir(0) + if err != nil { + fmt.Println(err) + return err + } + + jsonFiles := make([]File, 0) + + for _, f := range files { + jsonFiles = append(jsonFiles, File{ + Name: f.Name(), + IsDir: f.IsDir(), + Size: f.Size(), + LastModified: f.ModTime().Format("1/2/2006"), + }) + } + + return c.JSON(http.StatusOK, jsonFiles) +} + +func GetUsage(c echo.Context) error { + user := c.Get("user").(*models.User) + + fullPath := strings.Trim(c.Param("*"), "/") + basePath := fmt.Sprintf("%s/%s/%s/", os.Getenv("STORAGE_PATH"), user.ID, fullPath) + storageUsage, err := calculateStorageUsage(basePath) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, map[string]int64{"usage": storageUsage}) +} diff --git a/ui/assets/css/main.css b/ui/assets/css/main.css index ce51e27..9d8d55b 100644 --- a/ui/assets/css/main.css +++ b/ui/assets/css/main.css @@ -4,37 +4,47 @@ @layer base { :root { - --color-base: 250 244 237; - --color-surface: 255 250 243; - --color-overlay: 242 233 225; - --color-muted: 152 147 165; - --color-subtle: 121 117 147; - --color-text: 87 82 121; - --highlight-low: 33 32 46; - --highlight-med: 64 61 82; - --highlight-high: 82 79 103; + --color-base: 239 237 242; + --color-surface: 247 246 249; + --color-overlay: 242 240 245; + --color-muted: 146 142 175; + --color-subtle: 139 136 160; + --color-text: 14 13 17; + --highlight-low: 11 18 22; + --highlight-med: 32 37 38; + --highlight-high: 49 55 58; --color-foam: 86 148 159; - --color-love: 180 99 122; + --color-love: 220 100 130; --color-pine: 40 105 131; + + --nav-height: 48px; } } .dark { - --color-base: 25 23 36; - --color-surface: 31 29 46; - --color-overlay: 38 35 58; - --color-muted: 110 106 134; - --color-subtle: 144 140 170; - --color-text: 224 222 244; + --color-base: 14 13 17; + --color-surface: 21 18 28; + --color-overlay: 30 26 40; + --color-muted: 99 92 117; + --color-subtle: 152 146 171; + --color-text: 247 246 249; --highlight-low: 244 237 232; --highlight-med: 223 218 217; --highlight-high: 206 202 205; --color-foam: 156 207 216; --color-love: 235 111 146; --color-pine: 49 116 143; + + color-scheme: dark; +} + +*, ::before, ::after { + border-color: rgb(var(--color-muted) / 0.2); + @apply ease-[cubic-bezier(0.25,_1,_0.5,_1)]; } body { + background-color: rgb(var(--color-base)); color: rgb(var(--color-text)); } diff --git a/ui/bun.lockb b/ui/bun.lockb index 48a2d61..c5debe2 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 new file mode 100644 index 0000000..e7892eb --- /dev/null +++ b/ui/components/Breadcrumbs.vue @@ -0,0 +1,33 @@ + + + \ No newline at end of file diff --git a/ui/components/FileNav.vue b/ui/components/FileNav.vue new file mode 100644 index 0000000..d4e3747 --- /dev/null +++ b/ui/components/FileNav.vue @@ -0,0 +1,116 @@ + + + \ No newline at end of file diff --git a/ui/components/Footer.vue b/ui/components/Footer.vue new file mode 100644 index 0000000..9a39118 --- /dev/null +++ b/ui/components/Footer.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/ui/components/Input.vue b/ui/components/Input.vue index 446eb06..b47c693 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.vue b/ui/components/Nav.vue index ba53413..aff9e0f 100644 --- a/ui/components/Nav.vue +++ b/ui/components/Nav.vue @@ -1,2 +1,69 @@ + + \ No newline at end of file diff --git a/ui/components/UploadPane.vue b/ui/components/UploadPane.vue new file mode 100644 index 0000000..470eda9 --- /dev/null +++ b/ui/components/UploadPane.vue @@ -0,0 +1,239 @@ + + + \ No newline at end of file diff --git a/ui/composables/useUser.ts b/ui/composables/useUser.ts new file mode 100644 index 0000000..820079c --- /dev/null +++ b/ui/composables/useUser.ts @@ -0,0 +1,49 @@ +import type { User } from '~/types/user' +import { useFetch } from '#app' + +export const useUser = () => { + // Global state for storing the user + const user = useState('user', () => { return { fetched: false, user: {} } }) + + // Fetch the user only if it's uninitialized (i.e., null) + const getUser = async () => { + if (!user.value.fetched && import.meta.client) { + await fetchUser() + } + + return user.value.user + } + + const fetchUser = async () => { + try { + const { data, error } = await useFetch('/api/user') + user.value.fetched = true + + if (error.value || !data.value) { + throw new Error('Failed to fetch user') + } + + user.value.user = data.value + } catch (e) { + console.error(e.message) + user.value.user = {} + } + } + + // Manually set the user (e.g., after login/signup) + const setUser = (userData: User) => { + user.value.user = userData + } + + // Clear the user data (e.g., on logout) + const resetUser = () => { + user.value.user = {} + } + + return { + getUser, + setUser, + resetUser, + fetchUser, + } +} \ No newline at end of file diff --git a/ui/middleware/auth.ts b/ui/middleware/auth.ts new file mode 100644 index 0000000..1e4d5ab --- /dev/null +++ b/ui/middleware/auth.ts @@ -0,0 +1,15 @@ +import { useUser } from '~/composables/useUser' + +// We have server side things that does effectively this, but that wont stop SPA navigation +export default defineNuxtRouteMiddleware(async (to, from) => { + if (import.meta.server) { + return + } + + const { getUser } = useUser() + const user = await getUser() + + if (!user.id) { + return navigateTo('/login') + } +}) diff --git a/ui/middleware/unauth.ts b/ui/middleware/unauth.ts new file mode 100644 index 0000000..ae7f86a --- /dev/null +++ b/ui/middleware/unauth.ts @@ -0,0 +1,15 @@ +import { useUser } from '~/composables/useUser' + +// We have server side things that does effectively this, but that wont stop SPA navigation +export default defineNuxtRouteMiddleware(async (to, from) => { + if (import.meta.server) { + return + } + + const { getUser } = useUser() + const user = await getUser() + + if (user.id) { + return navigateTo('/home') + } +}) diff --git a/ui/pages/home.vue b/ui/pages/home.vue deleted file mode 100644 index 2eea24d..0000000 --- a/ui/pages/home.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/pages/home/[...name].vue b/ui/pages/home/[...name].vue new file mode 100644 index 0000000..84245b8 --- /dev/null +++ b/ui/pages/home/[...name].vue @@ -0,0 +1,266 @@ + + + diff --git a/ui/pages/index.vue b/ui/pages/index.vue index 43bc3ee..3a98d1f 100644 --- a/ui/pages/index.vue +++ b/ui/pages/index.vue @@ -1,10 +1,193 @@ + + - \ No newline at end of file diff --git a/ui/pages/login.vue b/ui/pages/login.vue index a9afe4b..c8d69ef 100644 --- a/ui/pages/login.vue +++ b/ui/pages/login.vue @@ -1,9 +1,10 @@ -