commit ce3925423f07e650ba64eb889b87af4f3bb2675e Author: Bram Date: Fri Mar 20 12:33:37 2026 +0100 Init diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..edc8a2b --- /dev/null +++ b/.env.template @@ -0,0 +1,6 @@ +PORT=8000 +JWT_SECRET= +ADMIN_PASSWORD= + +DOMAIN=http://localhost:8000 +USE_HTTPS=false \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3092c86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/** + +data/** +uploads/** +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72977ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o app ./cmd/server + +FROM alpine:latest + +WORKDIR /app + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /app/app . +COPY --from=builder /app/templates ./templates +COPY --from=builder /app/static ./static + +RUN mkdir -p /app/uploads + +ENV GIN_MODE=release + +EXPOSE 8000 + +CMD ["./app"] \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f947efd --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "ResendIt/internal/auth" + "ResendIt/internal/db" + "ResendIt/internal/file" + "ResendIt/internal/user" + "ResendIt/internal/util" + "ResendIt/internal/web" + "errors" + "fmt" + "html/template" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + err := godotenv.Load() + if err != nil { + fmt.Printf("Error loading .env file") + return + } + + dbCon, err := db.Connect() + if err != nil { + panic(fmt.Errorf("failed to connect database: %w", err)) + } + + err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}) + if err != nil { + fmt.Printf("Error migrating database: %v\n", err) + return + } + + r := gin.Default() + + r.MaxMultipartMemory = 10 << 30 + r.SetFuncMap(template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "humanSize": util.HumanSize, + }) + + r.LoadHTMLGlob("internal/templates/*.html") + //r.LoadHTMLGlob("internal/templates/new/*.html") + r.Static("/static", "./internal/static") + + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "hello", + }) + }) + + r.NoRoute(func(c *gin.Context) { + c.HTML(404, "error.html", nil) + }) + + authRepo := auth.NewRepository(dbCon) + authService := auth.NewService(authRepo) + authHandler := auth.NewHandler(authService) + + userRepo := user.NewRepository(dbCon) + userService := user.NewService(userRepo) + userHandler := user.NewHandler(userService) + + fileRepo := file.NewRepository(dbCon) + fileService := file.NewService(fileRepo, "./uploads") + fileHandler := file.NewHandler(fileService) + + createAdminUser(userService) + + apiRoute := r.Group("/api") + + auth.RegisterRoutes(apiRoute, authHandler) + user.RegisterRoutes(apiRoute, userHandler) + file.RegisterRoutes(apiRoute, fileHandler) + + webHandler := web.NewHandler(fileService) + web.RegisterRoutes(r, webHandler) + + err = r.Run(":" + os.Getenv("PORT")) + if err != nil { + return + } +} + +func createAdminUser(service *user.Service) { + //Check if admin user already exists + _, err := service.FindByUsername("admin") + if err == nil { + fmt.Println("Admin user already exists, skipping creation") + return + } else if !errors.Is(err, user.ErrUserNotFound) { + fmt.Printf("Error checking for admin user: %v\n", err) + return + } + + adminPassword, exists := os.LookupEnv("ADMIN_PASSWORD") + if !exists || adminPassword == "" { + fmt.Println("ADMIN_PASSWORD not set in environment variables") + fmt.Println("NO ADMIN ACCOUNT WILL BE CREATED") + return + } + + _, err = service.CreateUser("admin", adminPassword, "admin") + if err != nil { + fmt.Printf("Error creating admin user: %v\n", err) + } else { + fmt.Println("Admin user created successfully") + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a735b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" + +services: + sendit: + build: . + container_name: sendit + ports: + - "8000:8000" + + environment: + JWT_SECRET: supersecretkey + + volumes: + - ./uploads:/app/uploads + - ./data:/app/data + + restart: unless-stopped \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..10a591f --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module ResendIt + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.49.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bff7b62 --- /dev/null +++ b/go.sum @@ -0,0 +1,124 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..f211a69 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,116 @@ +package middleware + +import ( + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" +) + +var jwtSecret = []byte(os.Getenv("JWT_SECRET")) + +type Claims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + + var tokenString string + + // πŸ”₯ 1. Try cookie first (NEW) + cookie, err := c.Cookie("auth_token") + if err == nil && cookie != "" { + tokenString = cookie + } + + // πŸ”₯ 2. Fallback to Authorization header (for API tools / future SPA) + if tokenString == "" { + authHeader := c.GetHeader("Authorization") + + if authHeader != "" { + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && parts[0] == "Bearer" { + tokenString = parts[1] + } + } + } + + // ❌ No token at all + if tokenString == "" { + abortUnauthorized(c) + return + } + + // πŸ” Parse JWT + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrTokenSignatureInvalid + } + return jwtSecret, nil + }) + + if err != nil || !token.Valid { + abortUnauthorized(c) + return + } + + c.Set("user_id", claims.UserID) + c.Set("role", claims.Role) + + c.Next() + } +} + +func abortUnauthorized(c *gin.Context) { + if strings.Contains(c.GetHeader("Accept"), "text/html") { + c.Redirect(http.StatusFound, "/login") + } else { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + }) + } + c.Abort() +} + +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + + roleValue, exists := c.Get("role") + if !exists { + abortForbidden(c) + return + } + + userRole, ok := roleValue.(string) + if !ok { + abortForbidden(c) + return + } + + for _, allowed := range roles { + if userRole == allowed { + c.Next() + return + } + } + + abortForbidden(c) + } +} + +func abortForbidden(c *gin.Context) { + if strings.Contains(c.GetHeader("Accept"), "text/html") { + c.Redirect(http.StatusFound, "/") + } else { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + }) + } + c.Abort() +} diff --git a/internal/api/middleware/logging.go b/internal/api/middleware/logging.go new file mode 100644 index 0000000..c870d7c --- /dev/null +++ b/internal/api/middleware/logging.go @@ -0,0 +1 @@ +package middleware diff --git a/internal/auth/errors.go b/internal/auth/errors.go new file mode 100644 index 0000000..401bf23 --- /dev/null +++ b/internal/auth/errors.go @@ -0,0 +1,5 @@ +package auth + +import "errors" + +var ErrInvalidCredentials = errors.New("invalid credentials") diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..cb0f055 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,63 @@ +package auth + +import ( + "os" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(s *Service) *Handler { + return &Handler{service: s} +} + +func (h *Handler) Me(c *gin.Context) { + userID, _ := c.Get("user_id") + role, _ := c.Get("role") + + c.JSON(200, gin.H{ + "user_id": userID, + "role": role, + }) +} + +func (h *Handler) AdminCheck(c *gin.Context) { + c.JSON(200, gin.H{ + "message": "you are an admin", + }) +} + +func (h *Handler) Login(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request body"}) + return + } + + token, err := h.service.Login(req.Username, req.Password) + if err != nil { + c.JSON(401, gin.H{"error": "Invalid credentials"}) + return + } + + isSecure := os.Getenv("USE_HTTPS") == "true" + + c.SetCookie( + "auth_token", + token, + 3600*24, + "/", + os.Getenv("DOMAIN"), + isSecure, + true, // httpOnly (IMPORTANT) + ) + + c.JSON(200, gin.H{"token": token}) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..f199469 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,31 @@ +package auth + +import ( + "os" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +var jwtSecret = []byte(os.Getenv("JWT_SECRET")) + +type Claims struct { + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateJWT(username string, role string) (string, error) { + claims := Claims{ + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 24h expiration + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go new file mode 100644 index 0000000..88f6f21 --- /dev/null +++ b/internal/auth/repository.go @@ -0,0 +1,27 @@ +package auth + +import ( + "ResendIt/internal/user" + "errors" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db} +} + +func (r *Repository) FindByUsername(username string) (*user.User, error) { + var u user.User + if err := r.db.Where("username = ?", username).First(&u).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, user.ErrUserNotFound + } + return nil, err + } + return &u, nil +} diff --git a/internal/auth/routes.go b/internal/auth/routes.go new file mode 100644 index 0000000..a180f4d --- /dev/null +++ b/internal/auth/routes.go @@ -0,0 +1,23 @@ +package auth + +import ( + "ResendIt/internal/api/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(r *gin.RouterGroup, h *Handler) { + auth := r.Group("/auth") + + auth.POST("/login", h.Login) + + protected := auth.Group("/") + protected.Use(middleware.AuthMiddleware()) + + protected.GET("/me", h.Me) + + admin := protected.Group("/") + admin.Use(middleware.RequireRole("admin")) + + admin.GET("/admin-check", h.AdminCheck) +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..dd77a0e --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,32 @@ +package auth + +import ( + "ResendIt/internal/security" + "ResendIt/internal/user" + "errors" +) + +type Service struct { + repo *Repository +} + +func NewService(r *Repository) *Service { + return &Service{repo: r} +} + +func (s *Service) Login(username, password string) (string, error) { + u, err := s.repo.FindByUsername(username) + + if errors.Is(err, user.ErrUserNotFound) { + // Prevent user enumeration by returning a generic error message + return "", ErrInvalidCredentials + } else if err != nil { + return "", err + } + + if !security.CheckPassword(password, u.PasswordHash) { + return "", ErrInvalidCredentials + } + + return GenerateJWT(u.Username, u.Role) +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..3d01bbe --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,75 @@ +package db + +import ( + "fmt" + "os" + "path/filepath" + + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func Connect() (*gorm.DB, error) { + dbType := os.Getenv("DB_TYPE") + dsn := os.Getenv("DATABASE_URL") + + switch dbType { + case "sqlite": + return connectSQLite(dsn) + + case "postgres": + return connectPostgres(dsn) + + case "mysql": + return connectMySQL(dsn) + + default: + return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType) + } +} + +func connectSQLite(filePath string) (*gorm.DB, error) { + if filePath == "" { + filePath = "./data.db" + } + + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + db, err := gorm.Open(sqlite.Open(filePath), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to open SQLite database: %w", err) + } + + return db, nil +} + +func connectPostgres(dsn string) (*gorm.DB, error) { + if dsn == "" { + return nil, fmt.Errorf("DATABASE_URL is required for postgres") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to connect to Postgres: %w", err) + } + + return db, nil +} + +func connectMySQL(dsn string) (*gorm.DB, error) { + if dsn == "" { + return nil, fmt.Errorf("DATABASE_URL is required for mysql") + } + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to connect to MySQL: %w", err) + } + + return db, nil +} diff --git a/internal/file/handlers.go b/internal/file/handlers.go new file mode 100644 index 0000000..e86e6c7 --- /dev/null +++ b/internal/file/handlers.go @@ -0,0 +1,144 @@ +package file + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(s *Service) *Handler { + return &Handler{service: s} +} + +func (h *Handler) Upload(c *gin.Context) { + err := c.Request.ParseMultipartForm(0) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + + f, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) + return + } + defer f.Close() + + once := c.PostForm("once") == "true" + + durationStr := c.PostForm("duration") + hours, err := strconv.Atoi(durationStr) + if err != nil || hours <= 0 { + hours = 24 // default + } + + duration := time.Duration(hours) * time.Hour + + record, err := h.service.UploadFile( + file.Filename, + f, + once, + duration, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": record.ID, + "deletion_id": record.DeletionID, + "filename": record.Filename, + "size": record.Size, + "expires_at": record.ExpiresAt, + }) +} + +func (h *Handler) Download(c *gin.Context) { + id := c.Param("id") + + record, err := h.service.DownloadFile(id) + if err != nil { + c.HTML(http.StatusOK, "fileNotFound.html", nil) + return + } + c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, record.Filename)) + c.File(record.Path) +} + +func (h *Handler) Delete(c *gin.Context) { + id := c.Param("del_id") + + _, err := h.service.DeleteFileByDeletionID(id) + if err != nil { + c.HTML(http.StatusOK, "fileNotFound.html", nil) + return + } + + //c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + c.HTML(http.StatusOK, "deleted.html", nil) +} + +func (h *Handler) AdminList(c *gin.Context) { + records, err := h.service.repo.GetAll() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, records) +} + +func (h *Handler) AdminGet(c *gin.Context) { + id := c.Param("id") + + record, err := h.service.repo.GetByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + + c.File(record.Path) +} + +func (h *Handler) AdminDelete(c *gin.Context) { + id := c.Param("id") + + _, err := h.service.DeleteFileByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + + c.Redirect(301, "/admin") +} + +func (h *Handler) AdminForceDelete(c *gin.Context) { + id := c.Param("id") + + _, err := h.service.GetFileByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + + if _, err := h.service.ForceDelete(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Redirect(301, "/admin") +} diff --git a/internal/file/model.go b/internal/file/model.go new file mode 100644 index 0000000..d33545d --- /dev/null +++ b/internal/file/model.go @@ -0,0 +1,18 @@ +package file + +import ( + "time" +) + +type FileRecord struct { + ID string `gorm:"primaryKey" json:"id"` + DeletionID string `json:"deletion_id"` + Filename string `json:"filename"` + Path string `json:"-"` // file path on disk (not exposed via JSON) + ExpiresAt time.Time `json:"expires_at"` + DeleteAfterDownload bool `json:"delete_after_download"` + Size int64 `json:"size"` + DownloadCount int `json:"download_count"` + Deleted bool `json:"deleted"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/file/repository.go b/internal/file/repository.go new file mode 100644 index 0000000..47d94bc --- /dev/null +++ b/internal/file/repository.go @@ -0,0 +1,86 @@ +package file + +import ( + "errors" + + "gorm.io/gorm" +) + +var ErrFileNotFound = errors.New("file not found") + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(f *FileRecord) error { + return r.db.Create(f).Error +} + +func (r *Repository) GetAll() ([]FileRecord, error) { + var files []FileRecord + if err := r.db.Find(&files).Error; err != nil { + return nil, err + } + return files, nil +} + +func (r *Repository) GetByID(id string) (*FileRecord, error) { + var f FileRecord + if err := r.db.First(&f, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrFileNotFound + } + return nil, err + } + return &f, nil +} + +func (r *Repository) GetPaginated(limit, offset int) ([]FileRecord, int, error) { + var files []FileRecord + var count int64 + + if err := r.db.Model(&FileRecord{}).Count(&count).Error; err != nil { + return nil, 0, err + } + + if err := r.db. + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&files).Error; err != nil { + return nil, 0, err + } + + return files, int(count), nil +} + +func (r *Repository) GetByDeletionID(delID string) (*FileRecord, error) { + var f FileRecord + if err := r.db.First(&f, "deletion_id = ?", delID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrFileNotFound + } + return nil, err + } + return &f, nil +} + +func (r *Repository) IncrementDownload(f *FileRecord) error { + f.DownloadCount++ + return r.db.Save(f).Error +} + +// MarkDeleted Soft delete the record by setting Deleted to true +func (r *Repository) MarkDeleted(f *FileRecord) error { + f.Deleted = true + return r.db.Save(f).Error +} + +// Delete Permanently delete the record from the database +func (r *Repository) Delete(f *FileRecord) error { + return r.db.Delete(f).Error +} diff --git a/internal/file/routes.go b/internal/file/routes.go new file mode 100644 index 0000000..da16e82 --- /dev/null +++ b/internal/file/routes.go @@ -0,0 +1,28 @@ +package file + +import ( + "ResendIt/internal/api/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(r *gin.RouterGroup, h *Handler) { + files := r.Group("/files") + + files.POST("/upload", h.Upload) + files.GET("/download/:id", h.Download) + + files.GET("/delete/:del_id", h.Delete) + + adminRoutes := files.Group("/") + adminRoutes.Use(middleware.AuthMiddleware()) + adminRoutes.Use(middleware.RequireRole("admin")) + + adminRoutes.GET("/admin", h.AdminList) + adminRoutes.GET("/admin/:id", h.AdminGet) + + adminRoutes.GET("/admin/download/:id", h.AdminGet) + + adminRoutes.GET("/admin/delete/:id", h.AdminDelete) + adminRoutes.GET("/admin/delete/fr/:id", h.AdminForceDelete) +} diff --git a/internal/file/service.go b/internal/file/service.go new file mode 100644 index 0000000..5ae8fd2 --- /dev/null +++ b/internal/file/service.go @@ -0,0 +1,144 @@ +package file + +import ( + "io" + "os" + "time" + + "github.com/google/uuid" +) + +type Service struct { + repo *Repository + storageDir string +} + +func NewService(r *Repository, storageDir string) *Service { + if _, err := os.Stat(storageDir); os.IsNotExist(err) { + os.MkdirAll(storageDir, os.ModePerm) + } + + return &Service{repo: r, storageDir: storageDir} +} + +func (s *Service) UploadFile(filename string, data io.Reader, deleteAfterDownload bool, expiresAfter time.Duration) (*FileRecord, error) { + folderID := uuid.NewString() + folderPath := s.storageDir + "/" + folderID + + if err := os.MkdirAll(folderPath, os.ModePerm); err != nil { + return nil, err + } + + path := folderPath + "/" + filename + + out, err := os.Create(path) + if err != nil { + return nil, err + } + defer out.Close() + + size, err := io.Copy(out, data) + if err != nil { + return nil, err + } + + f := &FileRecord{ + ID: folderID, + DeletionID: uuid.NewString(), + Filename: filename, + Path: path, + Size: size, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(expiresAfter), + DeleteAfterDownload: deleteAfterDownload, + } + + if err := s.repo.Create(f); err != nil { + return nil, err + } + + return f, nil +} + +// DownloadFile Download a file +func (s *Service) DownloadFile(id string) (*FileRecord, error) { + f, err := s.repo.GetByID(id) + if err != nil { + return nil, err + } + + if f.Deleted || time.Now().After(f.ExpiresAt) { + return nil, ErrFileNotFound + } + + _ = s.repo.IncrementDownload(f) + + if f.DeleteAfterDownload { + _ = s.repo.MarkDeleted(f) + } + + return f, nil +} + +func (s *Service) DeleteFileByID(id string) (*FileRecord, error) { + f, err := s.repo.GetByID(id) + if err != nil { + return nil, err + } + + if f.Deleted { + return nil, ErrFileNotFound + } + + if err := s.repo.MarkDeleted(f); err != nil { + return nil, err + } + + return f, nil +} + +func (s *Service) DeleteFileByDeletionID(delID string) (*FileRecord, error) { + f, err := s.repo.GetByDeletionID(delID) + if err != nil { + return nil, err + } + + if f.Deleted { + return nil, ErrFileNotFound + } + + if err := s.repo.MarkDeleted(f); err != nil { + return nil, err + } + + return f, nil +} + +func (s *Service) ForceDelete(id string) (*FileRecord, error) { + f, err := s.repo.GetByID(id) + if err != nil { + return nil, err + } + + if err := os.RemoveAll(s.storageDir + "/" + f.ID); err != nil { + return nil, err + } + + if err := s.repo.Delete(f); err != nil { + return nil, err + } + + return f, nil +} + +func (s *Service) GetPaginatedFiles(limit, offset int) ([]FileRecord, int, error) { + return s.repo.GetPaginated(limit, offset) +} + +func (s *Service) GetFileByID(id string) (*FileRecord, error) { + return s.repo.GetByID(id) +} + +func (s *Service) GetFileByDeletionID(delID string) (*FileRecord, error) { + return s.repo.GetByDeletionID(delID) +} diff --git a/internal/security/password.go b/internal/security/password.go new file mode 100644 index 0000000..dd011d3 --- /dev/null +++ b/internal/security/password.go @@ -0,0 +1,13 @@ +package security + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/static/TOS.txt b/internal/static/TOS.txt new file mode 100644 index 0000000..ed31dbf --- /dev/null +++ b/internal/static/TOS.txt @@ -0,0 +1,35 @@ +Terms of Service (TOS) – Send.it +Effective Date: March 2026 + +Welcome to Send.it. By using our service, you agree to the following terms: + +1. No Ownership of Uploaded Files + You retain full ownership and responsibility for any files you upload. + We do not claim ownership of your content. + +2. Access to Files + While we may technically have the ability to view the files you upload, + we will not access them without a valid reason. Your privacy is important, + but absolute confidentiality cannot be guaranteed. + +3. User Responsibility + You are fully responsible for the content you upload. Send.it is not + responsible for any consequences arising from your uploaded files, + including legal or personal liability. + +4. File Availability and Deletion + Files may be deleted automatically based on the settings you choose + (expiration time or β€œburn after read”). Send.it does not guarantee + permanent storage of files. + +5. Prohibited Content + You may not upload content that is illegal, harmful, or violates the rights + of others. Send.it reserves the right to remove files that violate + applicable laws or these Terms. + +6. Disclaimer of Liability + Send.it provides the service β€œas-is.” We make no warranties regarding file + availability, security, or content. We are not liable for any damages, + loss, or issues arising from your use of the service. + +By uploading files, you acknowledge that you have read and agree to these Terms. \ No newline at end of file diff --git a/internal/static/favicon.ico b/internal/static/favicon.ico new file mode 100644 index 0000000..f33d010 Binary files /dev/null and b/internal/static/favicon.ico differ diff --git a/internal/static/logo.png b/internal/static/logo.png new file mode 100644 index 0000000..4f85a0d Binary files /dev/null and b/internal/static/logo.png differ diff --git a/internal/templates/admin.html b/internal/templates/admin.html new file mode 100644 index 0000000..1e1e507 --- /dev/null +++ b/internal/templates/admin.html @@ -0,0 +1,219 @@ + + + + + + Admin Console + + + + + + + + +
+
+
+

System_Admin

+
+ +
+ +
+ + + + + + + + + + + + + + {{if not .Files}} + + {{end}} + {{range .Files}} + + + + + + + + + + + + + + + + {{end}} + +
File_IdentifierSizeTimeline (In/Out)HitsBurnStatusSystem_Actions
Zero files in buffer
+ {{.Filename}} + {{humanSize .Size}} + CRT: {{.CreatedAt.Format "02/01/06 15:04"}} + EXP: {{.ExpiresAt.Format "02/01/06 15:04"}} + {{.DownloadCount}} + {{if .DeleteAfterDownload}} + YES + {{else}} + NO + {{end}} + + {{if .Deleted}} + REMOVED + {{else}} + LIVE + {{end}} + +
+ {{if not .Deleted}} +
+ +
+ {{end}} +
+ +
+
+
+
+ +
+
+ {{if gt .Page 1}} + Prev_Page + {{end}} + {{if lt .Page .TotalPages}} + Next_Page + {{end}} +
+ +
+
+ Data_Density: {{len .Files}} records | Page: {{.Page}}/{{.TotalPages}} +
+
+
+
+ + + + + \ No newline at end of file diff --git a/internal/templates/deleted.html b/internal/templates/deleted.html new file mode 100644 index 0000000..3be611b --- /dev/null +++ b/internal/templates/deleted.html @@ -0,0 +1,94 @@ + + + + + + File Deleted sucessfull + + + + + + + +
+ +
+ +
+ FILE DELETED SUCESSFULL +
+ +
+ The file has been absolutely obliterated. +
+ + + + + + + + + + + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/internal/templates/error.html b/internal/templates/error.html new file mode 100644 index 0000000..9ac12a6 --- /dev/null +++ b/internal/templates/error.html @@ -0,0 +1,99 @@ + + + + + + Nothing to see here + + + + + + + +
+ +
+ +
+ NOTHING TO SEE HERE +
+ +
+ MOVE ALONG +
+ +
+ This page is empty,
+ unavailable, private,
+ or intentionally left blank. +
+ +
+ GO BACK +
+ +
+ +
+ + + \ No newline at end of file diff --git a/internal/templates/fileNotFound.html b/internal/templates/fileNotFound.html new file mode 100644 index 0000000..00f9970 --- /dev/null +++ b/internal/templates/fileNotFound.html @@ -0,0 +1,89 @@ + + + + + + 404 β€” File Not Found + + + + + + +
+ +
+ +
404
+ +
+ FILE NOT FOUND πŸ’€ +
+ +
+ The requested file does not exist,
+ has expired, or was obliterated,
or my db is fucked. + We'll never know :D +
+ + + +
+ + +
+ + + \ No newline at end of file diff --git a/internal/templates/index.html b/internal/templates/index.html new file mode 100644 index 0000000..d5b5800 --- /dev/null +++ b/internal/templates/index.html @@ -0,0 +1,343 @@ + + + + + + Send.it + + + + + + + +
+ Send.it logo +
+
+

Send it

+
+ +
+
+ + +
+ Click to select or drop file +
+ + + +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ + + +
+
+ + +
+ +

A service by Brammie15

+ +
+ + +SUDO +TOS + + + \ No newline at end of file diff --git a/internal/templates/login.html b/internal/templates/login.html new file mode 100644 index 0000000..a9928ac --- /dev/null +++ b/internal/templates/login.html @@ -0,0 +1,169 @@ + + + + + + Login + + + + + + +
+ +
+

+ System Access +

+ + + ← BACK + +
+ + +
+ + {{if .Error}} +
+ ACCESS DENIED +
+ {{end}} + +
+ +
+
Username
+ +
+ +
+
Password
+ +
+ +
+ +
+ +
+ +
+
+ + + + + \ No newline at end of file diff --git a/internal/templates/old/admin.html b/internal/templates/old/admin.html new file mode 100644 index 0000000..2e2f0db --- /dev/null +++ b/internal/templates/old/admin.html @@ -0,0 +1,118 @@ + + + + + + Admin + + + + + +
+
+

System Console

+
+ ← BACK TO UPLOADER + LOGOUT +
+
+ +
+ + + + + + + + + + + + + + + {{if not .Files}} + + {{end}} + {{range .Files}} + + + + + + + + + + + + + + + + + + {{end}} + +
FilenameSizeCreatedExpiresHitsBurn afterStatusActions
No files found
+ {{.Filename}} + {{humanSize .Size}}{{.CreatedAt.Format "Jan 02, 2006 15:04"}}{{.ExpiresAt.Format "Jan 02, 2006 15:04"}}{{.DownloadCount}} + {{if .DeleteAfterDownload}} + YES + {{else}} + NO + {{end}} + + {{if .Deleted}} + REMOVED + {{else}} + LIVE + {{end}} + + {{if not .Deleted}} +
+ +
+ {{end}} +
+ +
+
+
+ +
+
+ {{if gt .Page 1}} + ← Prev + {{end}} + {{if lt .Page .TotalPages}} + Next β†’ + {{end}} +
+
+ +
+ Showing {{len .Files}} records β€” Page {{.Page}} of {{.TotalPages}} β€” Total Pages: {{.TotalPages}} +
+
+ + + diff --git a/internal/templates/old/index.html b/internal/templates/old/index.html new file mode 100644 index 0000000..1826b28 --- /dev/null +++ b/internal/templates/old/index.html @@ -0,0 +1,342 @@ + + + + + + Send.it + + + + + + +
+ Send.it logo +
+
+

Send it

+
+ +
+
+ + +
+ Click to select or drop file +
+ + + +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ + + +
+
+ + +
+ +

A service by Brammie15

+ +
+ + +SUDO +TOS + + + \ No newline at end of file diff --git a/internal/templates/old/login.html b/internal/templates/old/login.html new file mode 100644 index 0000000..93a641e --- /dev/null +++ b/internal/templates/old/login.html @@ -0,0 +1,202 @@ + + + + + + Login // System_Access + + + + + + +
+ +
+

+ Access +

+ + + ← RETREAT + +
+ + +
+ +
+ {{if .Error}} +
+ CRITICAL_AUTH_FAILURE +
+ {{end}} +
+ +
+ +
+
User_Identity
+ +
+ +
+
Secure_Passphrase
+ +
+ +
+ +
+ +
+ +
+ +

+ Session_Log: 0.0.0.0 // Node: Auth_Main +

+
+ + + + + \ No newline at end of file diff --git a/internal/templates/upload.html b/internal/templates/upload.html new file mode 100644 index 0000000..e8566ff --- /dev/null +++ b/internal/templates/upload.html @@ -0,0 +1,15 @@ + + + + Upload + + +

Upload File

+ +
+ + +
+ + + \ No newline at end of file diff --git a/internal/user/errors.go b/internal/user/errors.go new file mode 100644 index 0000000..c2e58b3 --- /dev/null +++ b/internal/user/errors.go @@ -0,0 +1,5 @@ +package user + +import "errors" + +var ErrUserNotFound = errors.New("user not found") diff --git a/internal/user/handler.go b/internal/user/handler.go new file mode 100644 index 0000000..f377728 --- /dev/null +++ b/internal/user/handler.go @@ -0,0 +1,32 @@ +package user + +import "github.com/gin-gonic/gin" + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Register(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "invalid request"}) + return + } + + user, err := h.service.CreateUser(req.Username, req.Password, req.Role) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role}) +} diff --git a/internal/user/model.go b/internal/user/model.go new file mode 100644 index 0000000..13e8d34 --- /dev/null +++ b/internal/user/model.go @@ -0,0 +1,10 @@ +package user + +import "gorm.io/gorm" + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null"` + PasswordHash string `gorm:"not null"` + Role string `gorm:"not null"` +} diff --git a/internal/user/repository.go b/internal/user/repository.go new file mode 100644 index 0000000..ddc9caa --- /dev/null +++ b/internal/user/repository.go @@ -0,0 +1,42 @@ +package user + +import ( + "errors" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) FindByUsername(username string) (*User, error) { + var u User + if err := r.db.Where("username = ?", username).First(&u).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + return &u, nil +} + +func (r *Repository) Create(u *User) error { + return r.db.Create(u).Error +} + +func (r *Repository) GetAll() ([]User, error) { + var users []User + if err := r.db.Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +func (r *Repository) Delete(id uint) error { + return r.db.Delete(&User{}, id).Error +} diff --git a/internal/user/routes.go b/internal/user/routes.go new file mode 100644 index 0000000..acbdba4 --- /dev/null +++ b/internal/user/routes.go @@ -0,0 +1,11 @@ +package user + +import ( + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(r *gin.RouterGroup, h *Handler) { + //auth := r.Group("/user") + + //auth.POST("/register", h.Register) +} diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..9cfcf43 --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,55 @@ +package user + +import ( + "ResendIt/internal/security" + "errors" +) + +var ErrCannotDeleteSelf = errors.New("cannot delete yourself") + +type Service struct { + repo *Repository +} + +func NewService(r *Repository) *Service { + return &Service{repo: r} +} + +// CreateUser creates a new user with the given username, password, and role +func (s *Service) CreateUser(username, password, role string) (*User, error) { + hash, err := security.HashPassword(password) + if err != nil { + return nil, err + } + + u := &User{ + Username: username, + PasswordHash: hash, + Role: role, + } + + if err := s.repo.Create(u); err != nil { + return nil, err + } + + return u, nil +} + +// GetAllUsers returns all users +func (s *Service) GetAllUsers() ([]User, error) { + return s.repo.GetAll() +} + +// DeleteUser deletes a user by ID +func (s *Service) DeleteUser(requesterID, targetID uint) error { + if requesterID == targetID { + return ErrCannotDeleteSelf + } + + return s.repo.Delete(targetID) +} + +// FindByUsername returns a user by username +func (s *Service) FindByUsername(username string) (*User, error) { + return s.repo.FindByUsername(username) +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..8acfc8d --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,19 @@ +package util + +import "fmt" + +func HumanSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", + float64(size)/float64(div), + "KMGTPE"[exp], + ) +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..031184b --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,66 @@ +package web + +import ( + "ResendIt/internal/file" + "strconv" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + fileService *file.Service +} + +func NewHandler(fileService *file.Service) *Handler { + return &Handler{ + fileService: fileService, + } +} + +// Homepage +func (h *Handler) Index(c *gin.Context) { + c.HTML(200, "index.html", gin.H{ + "title": "Home", + }) +} + +// Upload page +func (h *Handler) UploadPage(c *gin.Context) { + c.HTML(200, "upload.html", nil) +} + +func (h *Handler) LoginPage(c *gin.Context) { + c.HTML(200, "login.html", nil) +} + +func (h *Handler) AdminPage(c *gin.Context) { + pageStr := c.Query("page") + page, err := strconv.Atoi(pageStr) + if err != nil || page < 1 { + page = 1 + } + + limit := 10 + offset := (page - 1) * limit + + files, totalCount, err := h.fileService.GetPaginatedFiles(limit, offset) + if err != nil { + c.HTML(500, "admin.html", gin.H{ + "error": err.Error(), + }) + return + } + + totalPages := (totalCount + limit - 1) / limit + + c.HTML(200, "admin.html", gin.H{ + "Files": files, + "Page": page, + "TotalPages": totalPages, + }) +} + +func (h *Handler) Logout(c *gin.Context) { + c.SetCookie("auth_token", "", -1, "/", "", false, true) + c.Redirect(302, "/") +} diff --git a/internal/web/routes.go b/internal/web/routes.go new file mode 100644 index 0000000..0e00a7b --- /dev/null +++ b/internal/web/routes.go @@ -0,0 +1,20 @@ +package web + +import ( + "ResendIt/internal/api/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(r *gin.Engine, h *Handler) { + r.GET("/", h.Index) + r.GET("/upload", h.UploadPage) + r.GET("/login", h.LoginPage) + + adminRoutes := r.Group("/") + adminRoutes.Use(middleware.AuthMiddleware()) + adminRoutes.Use(middleware.RequireRole("admin")) + + adminRoutes.GET("/admin", h.AdminPage) + adminRoutes.GET("/logout", h.Logout) +}